}> */ 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}'" ); } } } // ─── 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." ); } } } } } // ─── 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." ); } } } }