diff --git a/phpunit.xml b/phpunit.xml index 17cc638..1e9232e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,8 +19,11 @@ tests/Architecture/DatabaseUpdatesContractTest.php tests/Architecture/FrontendTelemetryContractTest.php tests/Architecture/I18nKeyCompletenessContractTest.php + tests/Architecture/ModuleBoundaryContractTest.php + tests/Architecture/ModuleManifestContractTest.php tests/Architecture/ModuleRegistryContractTest.php - tests/Architecture/ModuleStructureContractTest.php + tests/Architecture/ModuleProviderContractTest.php + tests/Architecture/ModuleUiContractTest.php tests/Architecture/RepositoryInterfaceContractTest.php tests/Architecture/StatusTaxonomyContractTest.php @@ -58,7 +61,10 @@ tests/Architecture/ModuleRegistryContractTest.php - tests/Architecture/ModuleStructureContractTest.php + tests/Architecture/ModuleBoundaryContractTest.php + tests/Architecture/ModuleManifestContractTest.php + tests/Architecture/ModuleProviderContractTest.php + tests/Architecture/ModuleUiContractTest.php tests/Architecture/NoAddressBookHardcodingTest.php tests/Architecture/NoBookmarksHardcodingTest.php diff --git a/tests/Architecture/AbstractModuleStructureContractTestCase.php b/tests/Architecture/AbstractModuleStructureContractTestCase.php new file mode 100644 index 0000000..7fefc87 --- /dev/null +++ b/tests/Architecture/AbstractModuleStructureContractTestCase.php @@ -0,0 +1,53 @@ +}> */ + protected static array $modules = []; + + public static function setUpBeforeClass(): void + { + $modulesDir = realpath(__DIR__ . '/../../modules'); + if ($modulesDir === false || !is_dir($modulesDir)) { + return; + } + + $entries = scandir($modulesDir); + if ($entries === false) { + return; + } + + self::$modules = []; + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { + continue; + } + + $manifestFile = $modulesDir . '/' . $entry . '/module.php'; + if (!is_file($manifestFile)) { + continue; + } + + $raw = require $manifestFile; + if (!is_array($raw)) { + continue; + } + + $manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry); + self::$modules[] = [ + 'id' => $entry, + 'path' => $modulesDir . '/' . $entry, + 'manifest' => $manifest, + 'raw' => $raw, + ]; + } + } +} diff --git a/tests/Architecture/ModuleBoundaryContractTest.php b/tests/Architecture/ModuleBoundaryContractTest.php new file mode 100644 index 0000000..5a67180 --- /dev/null +++ b/tests/Architecture/ModuleBoundaryContractTest.php @@ -0,0 +1,83 @@ +getExtension() !== 'php') { + continue; + } + + $content = file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + + if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) { + $namespace = $match[1]; + foreach ($coreNamespacePrefixes as $corePrefix) { + self::assertFalse( + str_starts_with($namespace . '\\', $corePrefix), + "Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. " + . "Module classes must use MintyPHP\\Module\\* namespace." + ); + } + } + } + } + } + + public function testModulePagesDoNotImportCoreAbilityConstants(): void + { + foreach (self::$modules as $module) { + $pagesDir = $module['path'] . '/pages'; + if (!is_dir($pagesDir)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($pagesDir) + ); + + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $content = file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + + self::assertStringNotContainsString( + 'OperationsAuthorizationPolicy::ABILITY_', + $content, + "Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. " + . "Use the module's own AuthorizationPolicy instead." + ); + } + } + } +} diff --git a/tests/Architecture/ModuleManifestContractTest.php b/tests/Architecture/ModuleManifestContractTest.php new file mode 100644 index 0000000..2c8bfc2 --- /dev/null +++ b/tests/Architecture/ModuleManifestContractTest.php @@ -0,0 +1,142 @@ +id, + "Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'" + ); + } + } + + public function testManifestHasVersion(): void + { + foreach (self::$modules as $module) { + self::assertMatchesRegularExpression( + '/^\d+\.\d+\.\d+$/', + $module['manifest']->version, + "Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')" + ); + } + } + + public function testManifestHasAllRequiredKeys(): void + { + $requiredKeys = [ + 'id', 'version', 'enabled_by_default', 'load_order', + 'routes', 'public_paths', 'container_registrars', + 'ui_slots', 'search_resources', 'asset_groups', + 'scheduler_jobs', 'layout_context_providers', + 'session_providers', 'permissions', + ]; + + foreach (self::$modules as $module) { + foreach ($requiredKeys as $key) { + self::assertArrayHasKey( + $key, + $module['raw'], + "Module '{$module['id']}' manifest is missing required key '{$key}'" + ); + } + } + } + + 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); + } + + public function testModuleHasLibDirectory(): void + { + foreach (self::$modules as $module) { + self::assertDirectoryExists( + $module['path'] . '/lib', + "Module '{$module['id']}' must have a lib/ directory for PHP classes" + ); + } + } + + public function testRoutesHavePathAndTarget(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->routes as $idx => $route) { + self::assertArrayHasKey( + 'path', + $route, + "Module '{$module['id']}' route[{$idx}] is missing 'path'" + ); + self::assertArrayHasKey( + 'target', + $route, + "Module '{$module['id']}' route[{$idx}] is missing 'target'" + ); + self::assertNotEmpty( + trim((string) $route['path']), + "Module '{$module['id']}' route[{$idx}] has empty 'path'" + ); + self::assertNotEmpty( + trim((string) $route['target']), + "Module '{$module['id']}' route[{$idx}] has empty 'target'" + ); + } + } + } + + public function testModulesWithRoutesHavePagesDirectory(): void + { + foreach (self::$modules as $module) { + if (empty($module['manifest']->routes)) { + continue; + } + self::assertDirectoryExists( + $module['path'] . '/pages', + "Module '{$module['id']}' declares routes but has no pages/ directory" + ); + } + } +} diff --git a/tests/Architecture/ModuleProviderContractTest.php b/tests/Architecture/ModuleProviderContractTest.php new file mode 100644 index 0000000..89f0384 --- /dev/null +++ b/tests/Architecture/ModuleProviderContractTest.php @@ -0,0 +1,141 @@ +containerRegistrars as $registrar) { + self::assertTrue( + class_exists($registrar), + "Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist" + ); + } + } + } + + public function testSearchProviderClassesImplementContract(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->searchResources as $providerClass) { + self::assertTrue( + class_exists($providerClass), + "Module '{$module['id']}' search provider '{$providerClass}' class not found" + ); + $provider = new $providerClass(); + self::assertInstanceOf( + SearchResourceProvider::class, + $provider, + "Module '{$module['id']}' search provider must implement SearchResourceProvider" + ); + } + } + } + + public function testLayoutContextProviderClassesImplementContract(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->layoutContextProviders as $providerClass) { + self::assertTrue( + class_exists($providerClass), + "Module '{$module['id']}' layout context provider '{$providerClass}' class not found" + ); + $provider = new $providerClass(); + self::assertInstanceOf( + LayoutContextProvider::class, + $provider, + "Module '{$module['id']}' layout context provider must implement LayoutContextProvider" + ); + } + } + } + + public function testSessionProviderClassesImplementContract(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->sessionProviders as $providerClass) { + self::assertTrue( + class_exists($providerClass), + "Module '{$module['id']}' session provider '{$providerClass}' class not found" + ); + $provider = new $providerClass(); + self::assertInstanceOf( + SessionProvider::class, + $provider, + "Module '{$module['id']}' session provider must implement SessionProvider" + ); + } + } + } + + public function testAuthorizationPolicyClassesImplementContract(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->authorizationPolicies as $policyClass) { + self::assertTrue( + class_exists($policyClass), + "Module '{$module['id']}' authorization policy '{$policyClass}' class not found" + ); + self::assertTrue( + is_subclass_of($policyClass, AuthorizationPolicyInterface::class) + || in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true), + "Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface" + ); + } + } + } + + 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); + } + + 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); + } +} diff --git a/tests/Architecture/ModuleStructureContractTest.php b/tests/Architecture/ModuleStructureContractTest.php deleted file mode 100644 index cf74a32..0000000 --- a/tests/Architecture/ModuleStructureContractTest.php +++ /dev/null @@ -1,708 +0,0 @@ -}> */ - private static array $modules = []; - - public static function setUpBeforeClass(): void - { - $modulesDir = realpath(__DIR__ . '/../../modules'); - if ($modulesDir === false || !is_dir($modulesDir)) { - return; - } - - $entries = scandir($modulesDir); - if ($entries === false) { - return; - } - - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { - continue; - } - - $manifestFile = $modulesDir . '/' . $entry . '/module.php'; - if (!is_file($manifestFile)) { - continue; - } - - $raw = require $manifestFile; - if (!is_array($raw)) { - continue; - } - - $manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry); - self::$modules[] = [ - 'id' => $entry, - 'path' => $modulesDir . '/' . $entry, - 'manifest' => $manifest, - 'raw' => $raw, - ]; - } - } - - // ─── Manifest structure ────────────────────────────────────────── - - public function testAtLeastOneModuleExists(): void - { - self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/'); - } - - public function testEveryModuleHasManifest(): void - { - $modulesDir = $this->projectRootPath() . '/modules'; - if (!is_dir($modulesDir)) { - self::markTestSkipped('No modules/ directory'); - } - - $entries = scandir($modulesDir) ?: []; - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { - continue; - } - self::assertFileExists( - $modulesDir . '/' . $entry . '/module.php', - "Module directory '{$entry}' is missing module.php manifest" - ); - } - } - - public function testManifestIdMatchesDirectoryName(): void - { - foreach (self::$modules as $module) { - self::assertSame( - $module['id'], - $module['manifest']->id, - "Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'" - ); - } - } - - public function testManifestHasVersion(): void - { - foreach (self::$modules as $module) { - self::assertMatchesRegularExpression( - '/^\d+\.\d+\.\d+$/', - $module['manifest']->version, - "Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')" - ); - } - } - - public function testManifestHasAllRequiredKeys(): void - { - $requiredKeys = [ - 'id', 'version', 'enabled_by_default', 'load_order', - 'routes', 'public_paths', 'container_registrars', - 'ui_slots', 'search_resources', 'asset_groups', - 'scheduler_jobs', 'layout_context_providers', - 'session_providers', 'permissions', - ]; - - foreach (self::$modules as $module) { - foreach ($requiredKeys as $key) { - self::assertArrayHasKey( - $key, - $module['raw'], - "Module '{$module['id']}' manifest is missing required key '{$key}'" - ); - } - } - } - - 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 - { - foreach (self::$modules as $module) { - self::assertDirectoryExists( - $module['path'] . '/lib', - "Module '{$module['id']}' must have a lib/ directory for PHP classes" - ); - } - } - - // ─── Container registrars ──────────────────────────────────────── - - public function testContainerRegistrarClassesExist(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->containerRegistrars as $registrar) { - self::assertTrue( - class_exists($registrar), - "Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist" - ); - } - } - } - - // ─── Provider contracts ────────────────────────────────────────── - - public function testSearchProviderClassesImplementContract(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->searchResources as $providerClass) { - self::assertTrue( - class_exists($providerClass), - "Module '{$module['id']}' search provider '{$providerClass}' class not found" - ); - $provider = new $providerClass(); - self::assertInstanceOf( - SearchResourceProvider::class, - $provider, - "Module '{$module['id']}' search provider must implement SearchResourceProvider" - ); - } - } - } - - public function testLayoutContextProviderClassesImplementContract(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->layoutContextProviders as $providerClass) { - self::assertTrue( - class_exists($providerClass), - "Module '{$module['id']}' layout context provider '{$providerClass}' class not found" - ); - $provider = new $providerClass(); - self::assertInstanceOf( - LayoutContextProvider::class, - $provider, - "Module '{$module['id']}' layout context provider must implement LayoutContextProvider" - ); - } - } - } - - public function testSessionProviderClassesImplementContract(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->sessionProviders as $providerClass) { - self::assertTrue( - class_exists($providerClass), - "Module '{$module['id']}' session provider '{$providerClass}' class not found" - ); - $provider = new $providerClass(); - self::assertInstanceOf( - SessionProvider::class, - $provider, - "Module '{$module['id']}' session provider must implement SessionProvider" - ); - } - } - } - - // ─── Authorization policies ────────────────────────────────────── - - public function testAuthorizationPolicyClassesImplementContract(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->authorizationPolicies as $policyClass) { - self::assertTrue( - class_exists($policyClass), - "Module '{$module['id']}' authorization policy '{$policyClass}' class not found" - ); - self::assertTrue( - is_subclass_of($policyClass, AuthorizationPolicyInterface::class) - || in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true), - "Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface" - ); - } - } - } - - // ─── UI slot abilities ────────────────────────────────────────── - - /** - * Every 'permission' value in a module's UI slots must reference an ability - * declared as a constant on one of the module's authorization policies. - */ - public function testUiSlotAbilitiesMatchPolicySupports(): void - { - foreach (self::$modules as $module) { - // Collect all abilities supported by this module's policies - $supportedAbilities = []; - foreach ($module['manifest']->authorizationPolicies as $policyClass) { - if (!class_exists($policyClass)) { - continue; - } - $reflection = new \ReflectionClass($policyClass); - foreach ($reflection->getConstants() as $constValue) { - if (is_string($constValue)) { - $supportedAbilities[] = $constValue; - } - } - } - - if ($supportedAbilities === []) { - continue; - } - - // Check every permission value from UI slots - foreach ($module['manifest']->uiSlots as $slotName => $contributions) { - $contributionList = is_array($contributions) ? $contributions : [$contributions]; - foreach ($contributionList as $idx => $contribution) { - if (!is_array($contribution)) { - continue; - } - $ability = trim((string) ($contribution['permission'] ?? '')); - if ($ability === '') { - continue; - } - self::assertContains( - $ability, - $supportedAbilities, - "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' " - . "is not declared as a constant on any module authorization policy" - ); - } - } - } - } - - // ─── Permissions contract ─────────────────────────────────────── - - public function testPermissionsUseObjectContract(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->permissions as $index => $permission) { - self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key"); - self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description"); - self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty"); - self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty"); - self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1"); - self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1"); - } - } - } - - // ─── Scheduler jobs contract ──────────────────────────────────── - - public function testSchedulerJobsUseFullContractSchema(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->schedulerJobs as $index => $job) { - $required = [ - 'job_key', - 'handler', - 'label', - 'description', - 'default_enabled', - 'default_timezone', - 'default_schedule_type', - 'default_schedule_interval', - 'default_schedule_time', - 'default_schedule_weekdays_csv', - 'default_catchup_once', - 'allowed_schedule_types', - ]; - - foreach ($required as $requiredKey) { - self::assertArrayHasKey( - $requiredKey, - $job, - "Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'" - ); - } - - self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty"); - self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty"); - self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty"); - self::assertContains( - (string) $job['default_schedule_type'], - ['hourly', 'daily', 'weekly'], - "Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid" - ); - - $allowedTypes = array_values($job['allowed_schedule_types']); - self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty"); - foreach ($allowedTypes as $allowedType) { - self::assertContains( - (string) $allowedType, - ['hourly', 'daily', 'weekly'], - "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid" - ); - } - } - } - - self::addToAssertionCount(1); - } - - // ─── UI slots ──────────────────────────────────────────────────── - - public function testUiSlotContributionsHaveRequiredKeys(): void - { - $slotKeyRequirements = [ - 'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'], - 'search.resource_item' => ['key', 'label', 'base_url', 'permission'], - 'user.edit.aside_action' => ['key', 'type', 'label', 'permission'], - 'topbar.right_item' => ['key', 'template'], - 'layout.body_end_template' => ['key', 'template'], - 'layout.head_style' => ['key', 'path'], - 'runtime.component' => ['key', 'script'], - ]; - - foreach (self::$modules as $module) { - foreach ($module['manifest']->uiSlots as $slotName => $contributions) { - if (!isset($slotKeyRequirements[$slotName])) { - continue; - } - - $required = $slotKeyRequirements[$slotName]; - $contributionList = is_array($contributions) ? $contributions : [$contributions]; - - foreach ($contributionList as $idx => $contribution) { - if (!is_array($contribution)) { - continue; - } - foreach ($required as $requiredKey) { - self::assertArrayHasKey( - $requiredKey, - $contribution, - "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'" - ); - } - } - } - } - } - - public function testPanelTemplatesExist(): void - { - foreach (self::$modules as $module) { - $templateSlots = [ - 'aside.tab_panel' => 'panel_template', - 'topbar.right_item' => 'template', - 'layout.body_end_template' => 'template', - ]; - - foreach ($templateSlots as $slotName => $templateKey) { - $contributions = $module['manifest']->uiSlots[$slotName] ?? []; - if (!is_array($contributions)) { - continue; - } - foreach ($contributions as $contribution) { - if (!is_array($contribution)) { - continue; - } - $template = $contribution[$templateKey] ?? null; - if ($template === null) { - continue; - } - // Resolve relative paths against module basePath - $templatePath = (string) $template; - if (!str_starts_with($templatePath, '/')) { - $templatePath = $module['manifest']->basePath . '/' . $templatePath; - } - self::assertFileExists( - $templatePath, - "Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}" - ); - } - } - } - } - - public function testRuntimeComponentPhasesAreValid(): void - { - foreach (self::$modules as $module) { - $runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? []; - if (!is_array($runtimeComponents)) { - continue; - } - - foreach ($runtimeComponents as $index => $contribution) { - if (!is_array($contribution)) { - continue; - } - - $phase = strtolower(trim((string) ($contribution['phase'] ?? 'late'))); - self::assertContains( - $phase, - ['early', 'default', 'late'], - "Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'" - ); - } - } - } - - // ─── Routes ────────────────────────────────────────────────────── - - public function testRoutesHavePathAndTarget(): void - { - foreach (self::$modules as $module) { - foreach ($module['manifest']->routes as $idx => $route) { - self::assertArrayHasKey( - 'path', - $route, - "Module '{$module['id']}' route[{$idx}] is missing 'path'" - ); - self::assertArrayHasKey( - 'target', - $route, - "Module '{$module['id']}' route[{$idx}] is missing 'target'" - ); - self::assertNotEmpty( - trim((string) $route['path']), - "Module '{$module['id']}' route[{$idx}] has empty 'path'" - ); - self::assertNotEmpty( - trim((string) $route['target']), - "Module '{$module['id']}' route[{$idx}] has empty 'target'" - ); - } - } - } - - // ─── Pages directory ───────────────────────────────────────────── - - public function testModulesWithRoutesHavePagesDirectory(): void - { - foreach (self::$modules as $module) { - if (empty($module['manifest']->routes)) { - continue; - } - self::assertDirectoryExists( - $module['path'] . '/pages', - "Module '{$module['id']}' declares routes but has no pages/ directory" - ); - } - } - - // ─── Namespace ownership ─────────────────────────────────────── - - /** - * All PHP classes under modules//lib/ must use a MintyPHP\Module\\ - * namespace — never a Core namespace like MintyPHP\Service\ or MintyPHP\Repository\. - * - * This prevents module code from masquerading as core code and ensures - * `grep MintyPHP\Module\` reliably finds all module-owned classes. - */ - public function testModuleClassesUseModuleNamespace(): void - { - $coreNamespacePrefixes = [ - 'MintyPHP\\Service\\', - 'MintyPHP\\Repository\\', - 'MintyPHP\\Support\\', - 'MintyPHP\\Http\\', - 'MintyPHP\\App\\Container\\', - ]; - - foreach (self::$modules as $module) { - $libDir = $module['path'] . '/lib'; - if (!is_dir($libDir)) { - continue; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::LEAVES_ONLY - ); - - foreach ($iterator as $file) { - if ($file->getExtension() !== 'php') { - continue; - } - - $content = file_get_contents($file->getPathname()); - if ($content === false) { - continue; - } - - if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) { - $namespace = $match[1]; - foreach ($coreNamespacePrefixes as $corePrefix) { - self::assertFalse( - str_starts_with($namespace . '\\', $corePrefix), - "Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. " - . "Module classes must use MintyPHP\\Module\\* namespace." - ); - } - } - } - } - } - - // ─── 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..' prefix for all session keys. - * - * Scans session provider classes for $_SESSION[ and ->set( patterns and verifies - * that the key argument starts with 'module..'. - */ - 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 - { - foreach (self::$modules as $module) { - $pagesDir = $module['path'] . '/pages'; - if (!is_dir($pagesDir)) { - continue; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($pagesDir) - ); - - foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php') { - continue; - } - - $content = file_get_contents($file->getPathname()); - if ($content === false) { - continue; - } - - // Module pages should use their own policy constants, not Core's OperationsAuthorizationPolicy - self::assertStringNotContainsString( - 'OperationsAuthorizationPolicy::ABILITY_', - $content, - "Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. " - . "Use the module's own AuthorizationPolicy instead." - ); - } - } - } -} diff --git a/tests/Architecture/ModuleUiContractTest.php b/tests/Architecture/ModuleUiContractTest.php new file mode 100644 index 0000000..7420e2a --- /dev/null +++ b/tests/Architecture/ModuleUiContractTest.php @@ -0,0 +1,255 @@ +authorizationPolicies as $policyClass) { + if (!class_exists($policyClass)) { + continue; + } + $reflection = new \ReflectionClass($policyClass); + foreach ($reflection->getConstants() as $constValue) { + if (is_string($constValue)) { + $supportedAbilities[] = $constValue; + } + } + } + + if ($supportedAbilities === []) { + continue; + } + + foreach ($module['manifest']->uiSlots as $slotName => $contributions) { + $contributionList = is_array($contributions) ? $contributions : [$contributions]; + foreach ($contributionList as $idx => $contribution) { + if (!is_array($contribution)) { + continue; + } + $ability = trim((string) ($contribution['permission'] ?? '')); + if ($ability === '') { + continue; + } + self::assertContains( + $ability, + $supportedAbilities, + "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' " + . "is not declared as a constant on any module authorization policy" + ); + } + } + } + } + + public function testPermissionsUseObjectContract(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->permissions as $index => $permission) { + self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key"); + self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description"); + self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty"); + self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty"); + self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1"); + self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1"); + } + } + } + + public function testSchedulerJobsUseFullContractSchema(): void + { + foreach (self::$modules as $module) { + foreach ($module['manifest']->schedulerJobs as $index => $job) { + $required = [ + 'job_key', + 'handler', + 'label', + 'description', + 'default_enabled', + 'default_timezone', + 'default_schedule_type', + 'default_schedule_interval', + 'default_schedule_time', + 'default_schedule_weekdays_csv', + 'default_catchup_once', + 'allowed_schedule_types', + ]; + + foreach ($required as $requiredKey) { + self::assertArrayHasKey( + $requiredKey, + $job, + "Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'" + ); + } + + self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty"); + self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty"); + self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty"); + self::assertContains( + (string) $job['default_schedule_type'], + ['hourly', 'daily', 'weekly'], + "Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid" + ); + + $allowedTypes = array_values($job['allowed_schedule_types']); + self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty"); + foreach ($allowedTypes as $allowedType) { + self::assertContains( + (string) $allowedType, + ['hourly', 'daily', 'weekly'], + "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid" + ); + } + } + } + + self::addToAssertionCount(1); + } + + public function testUiSlotContributionsHaveRequiredKeys(): void + { + $slotKeyRequirements = [ + 'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'], + 'search.resource_item' => ['key', 'label', 'base_url', 'permission'], + 'user.edit.aside_action' => ['key', 'type', 'label', 'permission'], + 'topbar.right_item' => ['key', 'template'], + 'layout.body_end_template' => ['key', 'template'], + 'layout.head_style' => ['key', 'path'], + 'runtime.component' => ['key', 'script'], + ]; + + foreach (self::$modules as $module) { + foreach ($module['manifest']->uiSlots as $slotName => $contributions) { + if (!isset($slotKeyRequirements[$slotName])) { + continue; + } + + $required = $slotKeyRequirements[$slotName]; + $contributionList = is_array($contributions) ? $contributions : [$contributions]; + + foreach ($contributionList as $idx => $contribution) { + if (!is_array($contribution)) { + continue; + } + foreach ($required as $requiredKey) { + self::assertArrayHasKey( + $requiredKey, + $contribution, + "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'" + ); + } + } + } + } + } + + public function testPanelTemplatesExist(): void + { + foreach (self::$modules as $module) { + $templateSlots = [ + 'aside.tab_panel' => 'panel_template', + 'topbar.right_item' => 'template', + 'layout.body_end_template' => 'template', + ]; + + foreach ($templateSlots as $slotName => $templateKey) { + $contributions = $module['manifest']->uiSlots[$slotName] ?? []; + if (!is_array($contributions)) { + continue; + } + foreach ($contributions as $contribution) { + if (!is_array($contribution)) { + continue; + } + $template = $contribution[$templateKey] ?? null; + if ($template === null) { + continue; + } + $templatePath = (string) $template; + if (!str_starts_with($templatePath, '/')) { + $templatePath = $module['manifest']->basePath . '/' . $templatePath; + } + self::assertFileExists( + $templatePath, + "Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}" + ); + } + } + } + } + + public function testRuntimeComponentPhasesAreValid(): void + { + foreach (self::$modules as $module) { + $runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? []; + if (!is_array($runtimeComponents)) { + continue; + } + + foreach ($runtimeComponents as $index => $contribution) { + if (!is_array($contribution)) { + continue; + } + + $phase = strtolower(trim((string) ($contribution['phase'] ?? 'late'))); + self::assertContains( + $phase, + ['early', 'default', 'late'], + "Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'" + ); + } + } + } + + 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'] . '.'; + + 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" + ); + } + } + + 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); + } +} diff --git a/tests/README.md b/tests/README.md index 501cd5d..43c427a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,8 +32,8 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss. ## Naechste Refactor-Kandidaten -- `tests/Architecture/ModuleStructureContractTest.php` - `tests/Architecture/FrontendComponentRuntimeContractTest.php` - `tests/Architecture/FilterDrawerContractTest.php` - `tests/Architecture/PublicPageContractTest.php` - `tests/Architecture/CodexSkillsContractTest.php` +- `tests/Architecture/ModuleRegistryContractTest.php`