From b1a907b79c4e96d8a7a43b7f306ecb742199dee0 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 14 Apr 2026 08:23:04 +0200 Subject: [PATCH] feat(modules): harden module platform with event constants, validation checks, and CLI improvements - Add ModuleEvents constants class with all 8 event strings; update dispatch call sites - ValidateCommand: check AuthorizationPolicy container registration (check 20) - ValidateCommand: warn on unknown event_listeners keys (check 21) - make:module --simple flag for minimal manifests - module:deactivate reverse-dependency warning - 12 new PHPUnit tests covering all improvements Co-Authored-By: Claude Opus 4.6 --- core/App/Module/ModuleEvents.php | 42 ++++ core/Console/Commands/Make/ModuleCommand.php | 45 +++- .../Commands/Module/ValidateCommand.php | 55 +++++ core/Console/Runner/Module/ModuleRunner.php | 56 ++++- core/Service/Auth/AuthService.php | 7 +- .../Service/Scheduler/SchedulerRunService.php | 3 +- core/Service/User/UserAccountService.php | 15 +- tests/App/Module/ModuleEventsTest.php | 69 ++++++ tests/Console/MakeModuleCommandTest.php | 109 +++++++++ tests/Console/ValidateCommandTest.php | 227 ++++++++++++++++++ 10 files changed, 604 insertions(+), 24 deletions(-) create mode 100644 core/App/Module/ModuleEvents.php create mode 100644 tests/App/Module/ModuleEventsTest.php create mode 100644 tests/Console/MakeModuleCommandTest.php create mode 100644 tests/Console/ValidateCommandTest.php diff --git a/core/App/Module/ModuleEvents.php b/core/App/Module/ModuleEvents.php new file mode 100644 index 0000000..2f6e76d --- /dev/null +++ b/core/App/Module/ModuleEvents.php @@ -0,0 +1,42 @@ + */ + public static function all(): array + { + return [ + self::USER_LOGIN, + self::USER_LOGOUT, + self::USER_CREATED, + self::USER_DELETED, + self::USER_ACTIVATED, + self::USER_DEACTIVATED, + self::USER_ASSIGNMENT_CHANGED, + self::SCHEDULER_JOB_FAILED, + ]; + } + + private function __construct() + { + } +} diff --git a/core/Console/Commands/Make/ModuleCommand.php b/core/Console/Commands/Make/ModuleCommand.php index dd01418..d5a4602 100644 --- a/core/Console/Commands/Make/ModuleCommand.php +++ b/core/Console/Commands/Make/ModuleCommand.php @@ -35,17 +35,22 @@ final class ModuleCommand extends Command public function usage(): string { return <<<'USAGE' - Usage: php bin/console make:module + Usage: php bin/console make:module [--simple] Arguments: module-id Lowercase identifier (e.g. notifications, activity-log) Must match [a-z][a-z0-9-]* pattern. + Options: + --simple Generate a minimal manifest with only essential fields. + Ideal for lightweight UI-only modules. + Example: php bin/console make:module notifications + php bin/console make:module help-panel --simple Generates the full module directory structure with: - - module.php manifest (all fields, sensible defaults) + - module.php manifest (all fields or minimal with --simple) - ContainerRegistrar stub - AuthorizationPolicy stub - Empty pages/, templates/, web/css/, web/js/, tests/ directories @@ -96,8 +101,11 @@ final class ModuleCommand extends Command } // Generate files + $simple = $options['simple'] ?? false; $files = [ - 'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName), + 'module.php' => $simple + ? self::simpleManifestTemplate($moduleId, $pascalName) + : self::manifestTemplate($moduleId, $pascalName, $snakeName), "lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName), "lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName), ]; @@ -199,6 +207,37 @@ final class ModuleCommand extends Command PHP; } + private static function simpleManifestTemplate(string $id, string $pascalName): string + { + $registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class"; + $policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class"; + + return << '{$id}', + 'version' => '0.1.0', + 'enabled_by_default' => false, + 'load_order' => 100, + 'requires' => [], + 'container_registrars' => [ + {$registrarClass}, + ], + 'authorization_policies' => [ + {$policyClass}, + ], + ]; + + PHP; + } + private static function registrarTemplate(string $pascalName): string { return <<loadRegistrarSources($moduleDir, $manifest->containerRegistrars); + foreach ($manifest->authorizationPolicies as $policyClass) { + if (!class_exists($policyClass)) { + continue; // Already reported by check 9 + } + $shortName = (new \ReflectionClass($policyClass))->getShortName(); + $found = false; + foreach ($registrarSources as $source) { + if (str_contains($source, $shortName)) { + $found = true; + break; + } + } + if (!$found) { + $warnings[] = "AuthorizationPolicy '{$shortName}' is not referenced in any ContainerRegistrar — may not be registered in the container"; + } + } + + // 21. Event listener keys reference known events + $knownEvents = ModuleEvents::all(); + foreach ($manifest->eventListeners as $eventName => $handlers) { + if (!in_array($eventName, $knownEvents, true)) { + $warnings[] = "Event listener subscribes to unknown event '{$eventName}' — not defined in ModuleEvents"; + } + } + return [$errors, $warnings]; } + /** + * @param list $registrarClasses + * @return list Source code of each registrar file + */ + private function loadRegistrarSources(string $moduleDir, array $registrarClasses): array + { + $sources = []; + foreach ($registrarClasses as $class) { + if (!class_exists($class)) { + continue; + } + try { + $refClass = new \ReflectionClass($class); + $file = $refClass->getFileName(); + if ($file !== false && is_file($file)) { + $content = file_get_contents($file); + if ($content !== false) { + $sources[] = $content; + } + } + } catch (\ReflectionException) { + // Skip unresolvable classes + } + } + return $sources; + } + /** * @param list &$errors */ diff --git a/core/Console/Runner/Module/ModuleRunner.php b/core/Console/Runner/Module/ModuleRunner.php index 973b5d4..d4e5719 100644 --- a/core/Console/Runner/Module/ModuleRunner.php +++ b/core/Console/Runner/Module/ModuleRunner.php @@ -227,25 +227,31 @@ final class ModuleRunner implements ModuleRunnerInterface return 1; } - $manifest = ModuleManifestLoader::loadFromDisk( - ModuleCliRuntime::projectRoot() . '/modules', - $moduleId - ); + $modulesDir = ModuleCliRuntime::projectRoot() . '/modules'; + $manifest = ModuleManifestLoader::loadFromDisk($modulesDir, $moduleId); + + // Check for reverse dependencies (other modules that require this one) + $dependents = $this->findReverseDependencies($modulesDir, $moduleId); + $warning = ''; + if ($dependents !== []) { + $dependentList = implode(', ', $dependents); + $warning = "Warning: module(s) [{$dependentList}] declare '{$moduleId}' in their requires — deactivation may break them.\n"; + } $handlerClass = $manifest->deactivationHandler; if ($handlerClass === null) { - $message = "Module '{$moduleId}' has no deactivation_handler declared — nothing to do."; + $message = $warning . "Module '{$moduleId}' has no deactivation_handler declared — nothing to do."; return 0; } if (!class_exists($handlerClass)) { - $message = "Error: deactivation handler class '{$handlerClass}' not found."; + $message = $warning . "Error: deactivation handler class '{$handlerClass}' not found."; return 1; } $container = $GLOBALS['minty_app_container'] ?? null; if (!$container instanceof AppContainer) { - $message = 'Error: app container is not initialized.'; + $message = $warning . 'Error: app container is not initialized.'; return 1; } $handler = $container->has($handlerClass) @@ -253,17 +259,17 @@ final class ModuleRunner implements ModuleRunnerInterface : new $handlerClass(); if (!$handler instanceof ModuleDeactivationHandler) { - $message = "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler."; + $message = $warning . "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler."; return 1; } if ($dryRun) { - $message = "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'."; + $message = $warning . "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'."; return 0; } $handler->deactivate($container); - $message = "Deactivation handler for module '{$moduleId}' completed."; + $message = $warning . "Deactivation handler for module '{$moduleId}' completed."; return 0; }); @@ -345,4 +351,34 @@ final class ModuleRunner implements ModuleRunnerInterface 'message' => $step['error'] ?? $message, ]; } + + /** + * @return list Module IDs that declare $targetId in their requires + */ + private function findReverseDependencies(string $modulesDir, string $targetId): array + { + $dependents = []; + $entries = is_dir($modulesDir) ? (scandir($modulesDir) ?: []) : []; + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || $entry === $targetId) { + continue; + } + $manifestFile = $modulesDir . '/' . $entry . '/module.php'; + if (!is_file($manifestFile)) { + continue; + } + try { + $manifest = ModuleManifestLoader::loadFromDisk($modulesDir, $entry); + if (in_array($targetId, $manifest->requires, true)) { + $dependents[] = $entry; + } + } catch (\Throwable) { + // Skip unparseable manifests + } + } + + sort($dependents); + return $dependents; + } } diff --git a/core/Service/Auth/AuthService.php b/core/Service/Auth/AuthService.php index f637790..dc07ec2 100644 --- a/core/Service/Auth/AuthService.php +++ b/core/Service/Auth/AuthService.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\Auth; use MintyPHP\App\Module\ModuleEventDispatcher; +use MintyPHP\App\Module\ModuleEvents; use MintyPHP\Auth; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; @@ -132,7 +133,7 @@ class AuthService 'actor_tenant_id' => $this->currentTenantIdFromSession(), ]); - $this->eventDispatcher?->dispatch('user.login', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_LOGIN, [ 'user_id' => $userId, 'provider' => 'local', ]); @@ -226,7 +227,7 @@ class AuthService 'metadata' => ['provider' => $loginProvider], ]); - $this->eventDispatcher?->dispatch('user.login', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_LOGIN, [ 'user_id' => $userId, 'provider' => $loginProvider, ]); @@ -290,7 +291,7 @@ class AuthService $actorUserId = (int) ($this->sessionUser()['id'] ?? 0); $actorTenantId = $this->currentTenantIdFromSession(); - $this->eventDispatcher?->dispatch('user.logout', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_LOGOUT, [ 'user_id' => $actorUserId, ]); diff --git a/core/Service/Scheduler/SchedulerRunService.php b/core/Service/Scheduler/SchedulerRunService.php index eeadeb4..417ba76 100644 --- a/core/Service/Scheduler/SchedulerRunService.php +++ b/core/Service/Scheduler/SchedulerRunService.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\Scheduler; use MintyPHP\App\Module\ModuleEventDispatcher; +use MintyPHP\App\Module\ModuleEvents; use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus; use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType; use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult; @@ -423,7 +424,7 @@ class SchedulerRunService } try { - $this->eventDispatcher->dispatch('scheduler.job_failed', [ + $this->eventDispatcher->dispatch(ModuleEvents::SCHEDULER_JOB_FAILED, [ 'job_id' => (int) ($job['id'] ?? 0), 'job_key' => (string) ($job['job_key'] ?? ''), 'label' => (string) ($job['label'] ?? ''), diff --git a/core/Service/User/UserAccountService.php b/core/Service/User/UserAccountService.php index a3cc6ff..22ddfee 100644 --- a/core/Service/User/UserAccountService.php +++ b/core/Service/User/UserAccountService.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\User; use MintyPHP\App\Module\ModuleEventDispatcher; +use MintyPHP\App\Module\ModuleEvents; use MintyPHP\I18n; use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\User\UserListQueryRepositoryInterface; @@ -118,7 +119,7 @@ class UserAccountService 'target_uuid' => (string) ($user['uuid'] ?? ''), ]); - $this->eventDispatcher?->dispatch('user.deleted', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_DELETED, [ 'user_id' => $userId, 'uuid' => (string) ($user['uuid'] ?? ''), 'actor_user_id' => $currentUserId, @@ -191,7 +192,7 @@ class UserAccountService ]); foreach ($deletedUsers as $deletedUser) { - $this->eventDispatcher?->dispatch('user.deleted', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_DELETED, [ 'user_id' => (int) $deletedUser['id'], 'uuid' => (string) $deletedUser['uuid'], 'actor_user_id' => $currentUserId, @@ -336,7 +337,7 @@ class UserAccountService ], ]); - $this->eventDispatcher?->dispatch('user.created', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_CREATED, [ 'user_id' => $userId, 'uuid' => is_string($uuid) ? $uuid : '', 'actor_user_id' => $currentUserId, @@ -467,7 +468,7 @@ class UserAccountService $tenantIds = $this->extractTenantIdsFromAssignments( $this->safeAssignmentsForUser($userId) ); - $eventType = ((int) $form['active'] === 1) ? 'user.activated' : 'user.deactivated'; + $eventType = ((int) $form['active'] === 1) ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED; $this->eventDispatcher?->dispatch($eventType, [ 'user_id' => $userId, 'uuid' => (string) ($existing['uuid'] ?? ''), @@ -521,7 +522,7 @@ class UserAccountService $tenantIds = $this->extractTenantIdsFromAssignments( $this->safeAssignmentsForUser($userId) ); - $this->eventDispatcher?->dispatch($active ? 'user.activated' : 'user.deactivated', [ + $this->eventDispatcher?->dispatch($active ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED, [ 'user_id' => $userId, 'uuid' => (string) ($user['uuid'] ?? ''), 'display_name' => trim((string) ($user['display_name'] ?? '')), @@ -587,7 +588,7 @@ class UserAccountService $tenantIds = $this->extractTenantIdsFromAssignments( $this->safeAssignmentsForUser($targetUserId) ); - $this->eventDispatcher?->dispatch($active ? 'user.activated' : 'user.deactivated', [ + $this->eventDispatcher?->dispatch($active ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED, [ 'user_id' => $targetUserId, 'uuid' => (string) ($user['uuid'] ?? ''), 'display_name' => trim((string) ($user['display_name'] ?? '')), @@ -801,7 +802,7 @@ class UserAccountService return; } - $this->eventDispatcher?->dispatch('user.assignment_changed', [ + $this->eventDispatcher?->dispatch(ModuleEvents::USER_ASSIGNMENT_CHANGED, [ 'user_id' => $userId, 'uuid' => (string) ($user['uuid'] ?? ''), 'display_name' => trim((string) ($user['display_name'] ?? '')), diff --git a/tests/App/Module/ModuleEventsTest.php b/tests/App/Module/ModuleEventsTest.php new file mode 100644 index 0000000..467d856 --- /dev/null +++ b/tests/App/Module/ModuleEventsTest.php @@ -0,0 +1,69 @@ +getConstants(); + + self::assertNotEmpty($constants, 'ModuleEvents should define at least one constant'); + self::assertSame( + array_values($constants), + ModuleEvents::all(), + 'ModuleEvents::all() must return exactly the values of all class constants' + ); + } + + public function testConstantValuesMatchExpectedEventStrings(): void + { + self::assertSame('user.login', ModuleEvents::USER_LOGIN); + self::assertSame('user.logout', ModuleEvents::USER_LOGOUT); + self::assertSame('user.created', ModuleEvents::USER_CREATED); + self::assertSame('user.deleted', ModuleEvents::USER_DELETED); + self::assertSame('user.activated', ModuleEvents::USER_ACTIVATED); + self::assertSame('user.deactivated', ModuleEvents::USER_DEACTIVATED); + self::assertSame('user.assignment_changed', ModuleEvents::USER_ASSIGNMENT_CHANGED); + self::assertSame('scheduler.job_failed', ModuleEvents::SCHEDULER_JOB_FAILED); + } + + public function testAllReturnsUniqueValues(): void + { + $all = ModuleEvents::all(); + self::assertSame($all, array_unique($all), 'ModuleEvents::all() must not contain duplicates'); + } + + public function testClassIsNotInstantiable(): void + { + $reflection = new \ReflectionClass(ModuleEvents::class); + self::assertFalse($reflection->isInstantiable(), 'ModuleEvents should not be instantiable'); + } + + public function testDispatchCallSitesUseConstants(): void + { + $root = dirname(__DIR__, 3); + $files = [ + $root . '/core/Service/Auth/AuthService.php', + $root . '/core/Service/User/UserAccountService.php', + $root . '/core/Service/Scheduler/SchedulerRunService.php', + ]; + + foreach ($files as $file) { + $content = file_get_contents($file); + self::assertIsString($content, "Could not read {$file}"); + + // Ensure no raw string dispatch calls remain (except in ternaries which use constants) + preg_match_all('/->dispatch\(\s*[\'"]([^"\']+)[\'"]\s*,/', $content, $matches); + self::assertSame( + [], + $matches[1], + basename($file) . ' still uses raw string event names: ' . implode(', ', $matches[1]) + ); + } + } +} diff --git a/tests/Console/MakeModuleCommandTest.php b/tests/Console/MakeModuleCommandTest.php new file mode 100644 index 0000000..b9b49ac --- /dev/null +++ b/tests/Console/MakeModuleCommandTest.php @@ -0,0 +1,109 @@ + Module IDs to clean up */ + private array $cleanup = []; + + protected function setUp(): void + { + $this->modulesDir = dirname(__DIR__, 2) . '/modules'; + } + + protected function tearDown(): void + { + foreach ($this->cleanup as $id) { + $this->removeDir($this->modulesDir . '/' . $id); + } + } + + public function testSimpleFlagGeneratesMinimalManifest(): void + { + $id = 'ztest-simple-scaffold'; + $this->cleanup[] = $id; + + $command = new ModuleCommand(); + $exitCode = @$command->execute([$id], ['simple' => true]); + + self::assertSame(0, $exitCode); + + $manifestFile = $this->modulesDir . '/' . $id . '/module.php'; + self::assertFileExists($manifestFile); + + $manifest = require $manifestFile; + self::assertIsArray($manifest); + + // Essential fields present + self::assertSame($id, $manifest['id']); + self::assertSame('0.1.0', $manifest['version']); + self::assertArrayHasKey('container_registrars', $manifest); + self::assertArrayHasKey('authorization_policies', $manifest); + + // Verbose fields NOT present in simple mode + self::assertArrayNotHasKey('routes', $manifest); + self::assertArrayNotHasKey('ui_slots', $manifest); + self::assertArrayNotHasKey('permissions', $manifest); + self::assertArrayNotHasKey('scheduler_jobs', $manifest); + self::assertArrayNotHasKey('event_listeners', $manifest); + } + + public function testDefaultModeGeneratesFullManifest(): void + { + $id = 'ztest-full-scaffold'; + $this->cleanup[] = $id; + + $command = new ModuleCommand(); + $exitCode = @$command->execute([$id], []); + + self::assertSame(0, $exitCode); + + $manifestFile = $this->modulesDir . '/' . $id . '/module.php'; + $manifest = require $manifestFile; + + // Full mode includes all fields + self::assertArrayHasKey('routes', $manifest); + self::assertArrayHasKey('ui_slots', $manifest); + self::assertArrayHasKey('permissions', $manifest); + self::assertArrayHasKey('event_listeners', $manifest); + } + + public function testSimpleManifestIsValidForManifestLoader(): void + { + $id = 'ztest-simple-valid'; + $this->cleanup[] = $id; + + $command = new ModuleCommand(); + $exitCode = @$command->execute([$id], ['simple' => true]); + self::assertSame(0, $exitCode); + + $manifest = \MintyPHP\App\Module\ModuleManifestLoader::loadFromDisk($this->modulesDir, $id); + self::assertSame($id, $manifest->id); + self::assertSame('0.1.0', $manifest->version); + } + + 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); + } +} diff --git a/tests/Console/ValidateCommandTest.php b/tests/Console/ValidateCommandTest.php new file mode 100644 index 0000000..e0f6b16 --- /dev/null +++ b/tests/Console/ValidateCommandTest.php @@ -0,0 +1,227 @@ + Module IDs to clean up */ + private array $cleanup = []; + + private \ReflectionMethod $validateModule; + private ValidateCommand $command; + + protected function setUp(): void + { + $this->modulesDir = dirname(__DIR__, 2) . '/modules'; + $this->command = new ValidateCommand(); + $this->validateModule = new \ReflectionMethod($this->command, 'validateModule'); + } + + protected function tearDown(): void + { + foreach ($this->cleanup as $id) { + $this->removeDir($this->modulesDir . '/' . $id); + } + } + + public function testValidateWarnsOnUnknownEventListenerKey(): void + { + $id = 'ztest-unknown-event'; + $this->scaffoldModule($id, <<<'PHP' + 'ztest-unknown-event', + 'version' => '0.1.0', + 'event_listeners' => [ + 'user.created' => [ + ['class' => 'NonExistent\Listener', 'method' => 'handle'], + ], + 'totally.unknown.event' => [ + ['class' => 'NonExistent\Listener', 'method' => 'handle'], + ], + ], + ]; + PHP); + + [$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id); + + $warningText = implode("\n", $warnings); + self::assertStringContainsString('totally.unknown.event', $warningText); + self::assertStringContainsString('not defined in ModuleEvents', $warningText); + // user.created is a known event — should NOT trigger a warning + self::assertStringNotContainsString("unknown event 'user.created'", $warningText); + } + + public function testValidateDoesNotWarnOnKnownEventKeys(): void + { + $id = 'ztest-known-events'; + $this->scaffoldModule($id, <<<'PHP' + 'ztest-known-events', + 'version' => '0.1.0', + 'event_listeners' => [ + 'user.created' => [ + ['class' => 'NonExistent\Listener', 'method' => 'handle'], + ], + 'scheduler.job_failed' => [ + ['class' => 'NonExistent\Listener', 'method' => 'handle'], + ], + ], + ]; + PHP); + + [$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id); + + $warningText = implode("\n", $warnings); + self::assertStringNotContainsString('not defined in ModuleEvents', $warningText); + } + + public function testValidateWarnsWhenPolicyNotReferencedInRegistrar(): void + { + $id = 'ztest-policy-missing'; + $moduleDir = $this->modulesDir . '/' . $id; + mkdir($moduleDir . '/lib/Module/ZtestPolicyMissing', 0777, true); + $this->cleanup[] = $id; + + file_put_contents( + $moduleDir . '/lib/Module/ZtestPolicyMissing/ZtestPolicyMissingContainerRegistrar.php', + <<<'PHP' + 'ztest-policy-missing', + 'version' => '0.1.0', + 'container_registrars' => [\MintyPHP\Module\ZtestPolicyMissing\ZtestPolicyMissingContainerRegistrar::class], + 'authorization_policies' => [\MintyPHP\Module\ZtestPolicyMissing\ZtestPolicyMissingAuthorizationPolicy::class], + ]; + PHP); + + [$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id); + + $warningText = implode("\n", $warnings); + self::assertStringContainsString('ZtestPolicyMissingAuthorizationPolicy', $warningText); + self::assertStringContainsString('not referenced in any ContainerRegistrar', $warningText); + } + + public function testValidateNoWarningWhenPolicyReferencedInRegistrar(): void + { + $id = 'ztest-policy-ok'; + $moduleDir = $this->modulesDir . '/' . $id; + mkdir($moduleDir . '/lib/Module/ZtestPolicyOk', 0777, true); + $this->cleanup[] = $id; + + file_put_contents( + $moduleDir . '/lib/Module/ZtestPolicyOk/ZtestPolicyOkContainerRegistrar.php', + <<<'PHP' + set(ZtestPolicyOkAuthorizationPolicy::class, static fn () => new ZtestPolicyOkAuthorizationPolicy()); + } + } + PHP + ); + + file_put_contents( + $moduleDir . '/lib/Module/ZtestPolicyOk/ZtestPolicyOkAuthorizationPolicy.php', + <<<'PHP' + 'ztest-policy-ok', + 'version' => '0.1.0', + 'container_registrars' => [\MintyPHP\Module\ZtestPolicyOk\ZtestPolicyOkContainerRegistrar::class], + 'authorization_policies' => [\MintyPHP\Module\ZtestPolicyOk\ZtestPolicyOkAuthorizationPolicy::class], + ]; + PHP); + + [$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id); + + self::assertSame([], $errors); + $warningText = implode("\n", $warnings); + self::assertStringNotContainsString('not referenced', $warningText); + } + + private function scaffoldModule(string $id, string $manifestContent): void + { + $moduleDir = $this->modulesDir . '/' . $id; + mkdir($moduleDir . '/lib', 0777, true); + file_put_contents($moduleDir . '/module.php', $manifestContent); + $this->cleanup[] = $id; + } + + 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); + } +}