diff --git a/tests/Unit/ArchitectureTest.php b/tests/Unit/ArchitectureTest.php index b6f8d642..f9b86f2e 100644 --- a/tests/Unit/ArchitectureTest.php +++ b/tests/Unit/ArchitectureTest.php @@ -103,6 +103,32 @@ final class ArchitectureTest extends TestCase { } } + /** + * Resolve a module's on-disk directory basename by its canonical manifest name. + * + * Module directories use the `{name}_{hash5}` convention, so the basename is not + * the canonical name. Tests that need a specific module must resolve it via its + * module.json `name`, never by assuming the directory is named after the module. + */ + private function moduleDirBasename(string $canonicalName): ?string { + $dirs = new FilesystemIterator(self::MODULES_DIR, FilesystemIterator::SKIP_DOTS); + foreach ($dirs as $entry) { + /** @var SplFileInfo $entry */ + if (!$entry->isDir()) { + continue; + } + $manifest = $entry->getRealPath() . '/module.json'; + if (!is_file($manifest)) { + continue; + } + $data = json_decode((string) file_get_contents($manifest), true); + if (is_array($data) && ($data['name'] ?? null) === $canonicalName) { + return $entry->getBasename(); + } + } + return null; + } + // ── tests ───────────────────────────────────────────────────────────────── /** @@ -127,7 +153,10 @@ final class ArchitectureTest extends TestCase { * - CLI_GLOBAL_DB_EXEMPT — cron/CLI classes using admin.php bootstrap (temporary, see R4-3) */ public function testNoWebContextModuleUsesGlobalDb(): void { - foreach ($this->moduleFiles(excludeModuleDirs: ['ministra']) as $relative => $content) { + $ministraDir = $this->moduleDirBasename('ministra'); + $exclude = $ministraDir !== null ? [$ministraDir] : []; + + foreach ($this->moduleFiles(excludeModuleDirs: $exclude) as $relative => $content) { $basename = basename($relative); if (in_array($basename, self::CLI_GLOBAL_DB_EXEMPT, true)) { @@ -149,7 +178,8 @@ final class ArchitectureTest extends TestCase { /** * Every module entry-point (*Module.php) must declare the canonical namespace. * - * Convention: XcVm\Module\{Pascal} where Pascal = PascalCase of the directory name. + * Convention: XcVm\Module\{Pascal} where Pascal = PascalCase of the canonical + * manifest name (module.json `name`), NOT the `{name}_{hash5}` directory basename. * Non-entry-point files (controllers, services, cron) are not yet required — see R4-3. */ public function testModuleEntryPointsHaveCorrectNamespace(): void { @@ -162,8 +192,17 @@ final class ArchitectureTest extends TestCase { continue; } - $dirName = $entry->getBasename(); - $pascal = implode('', array_map('ucfirst', explode('-', $dirName))); + $manifest = $entry->getRealPath() . '/module.json'; + if (!is_file($manifest)) { + continue; + } + $data = json_decode((string) file_get_contents($manifest), true); + $name = is_array($data) ? (string) ($data['name'] ?? '') : ''; + if ($name === '') { + continue; + } + + $pascal = implode('', array_map('ucfirst', explode('-', $name))); $moduleFile = $entry->getRealPath() . '/' . $pascal . 'Module.php'; if (!file_exists($moduleFile)) { diff --git a/tests/Unit/InterfaceContractTest.php b/tests/Unit/InterfaceContractTest.php index 8abc7a14..3fd564e5 100644 --- a/tests/Unit/InterfaceContractTest.php +++ b/tests/Unit/InterfaceContractTest.php @@ -264,9 +264,11 @@ final class InterfaceContractTest extends TestCase { public function testMinistraModuleImplementsBoundaryInterface(): void { if (!class_exists(\XcVm\Module\Ministra\MinistraModule::class)) { - $file = defined('MAIN_HOME') - ? MAIN_HOME . 'Modules/ministra/MinistraModule.php' - : __DIR__ . '/../../src/Modules/ministra/MinistraModule.php'; + // Module directories use the `{name}_{hash5}` convention, so glob for the + // ministra dir rather than assuming a bare `Modules/ministra/` path. + $base = defined('MAIN_HOME') ? MAIN_HOME . 'Modules' : __DIR__ . '/../../src/Modules'; + $matches = glob($base . '/ministra*/MinistraModule.php'); + $file = $matches[0] ?? ($base . '/ministra/MinistraModule.php'); require_once $file; } $rc = new ReflectionClass(\XcVm\Module\Ministra\MinistraModule::class); diff --git a/tests/Unit/ModuleLoaderTest.php b/tests/Unit/ModuleLoaderTest.php index dc429bdb..d0123b72 100644 --- a/tests/Unit/ModuleLoaderTest.php +++ b/tests/Unit/ModuleLoaderTest.php @@ -26,6 +26,115 @@ final class ModuleLoaderTest extends TestCase { $this->assertTrue($loader->isLoaded('healthy-beta')); } + // ── update-source manifest block (P2) ───────────────────────────────── + + public function testNormalizeUpdateBlockDefaultsToBundled(): void { + $u = ModuleLoader::normalizeUpdateBlock(['name' => 'watch'], 'watch'); + $this->assertSame('bundled', $u['source']); + $this->assertSame('watch', $u['slug']); // slug defaults to the module name + $this->assertSame('stable', $u['channel']); + $this->assertSame('', $u['repository']); + } + + public function testNormalizeUpdateBlockReadsGitSource(): void { + $u = ModuleLoader::normalizeUpdateBlock([ + 'name' => 'watch', + 'update' => [ + 'source' => 'GIT', + 'repository' => 'https://github.com/Vateron-Media/xc_vm-module-watch', + 'channel' => 'Beta', + ], + ], 'watch'); + $this->assertSame('git', $u['source']); + $this->assertSame('https://github.com/Vateron-Media/xc_vm-module-watch', $u['repository']); + $this->assertSame('beta', $u['channel']); + $this->assertSame('watch', $u['slug']); + } + + public function testNormalizeUpdateBlockUnknownSourceFallsBackToBundled(): void { + $u = ModuleLoader::normalizeUpdateBlock(['name' => 'x', 'update' => ['source' => 'ftp']], 'x'); + $this->assertSame('bundled', $u['source']); + } + + // ── {name}_{hash5} directory convention ─────────────────────────────── + + public function testLoadsModuleFromHashSuffixedDirectory(): void { + $root = $this->createModulesRoot(); + // Directory is {name}_{hash5}; the canonical name in the manifest is 'hashmod'. + $dir = $root . '/hashmod_ab123'; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'hashmod', + 'hash_id' => 'ab123def4567890ab123def4567890ff', + 'version' => '1.0.0', + 'requires_core' => '>=2.0', + 'environment' => 'main', + 'dependencies' => [], + ])); + file_put_contents($dir . '/HashmodModule.php', + "loadAll($root); + + // Loaded under the canonical manifest name, not the hash-suffixed directory. + $this->assertTrue($loader->isLoaded('hashmod')); + $this->assertFalse($loader->isLoaded('hashmod_ab123')); + } + + public function testLoadAllSkipsBrokenModuleInsteadOfBrickingThePanel(): void { + $root = $this->createModulesRoot(); + // 'broken-mod' has a manifest but no class file → load() fails. A single + // broken/half-deployed module must NOT abort the whole load (which would + // brick the panel and CLI); a healthy sibling must still load. + $dir = $root . '/broken-mod'; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'broken-mod', + 'version' => '1.0.0', + 'requires_core' => '>=2.0', + 'environment' => 'main', + 'dependencies' => [], + ])); + // No BrokenModModule.php on disk → class cannot be loaded. + $this->createModule($root, 'healthy-sibling'); + + $loader = new ModuleLoader(); + $loader->loadAll($root); // must not throw + + $this->assertFalse($loader->isLoaded('broken-mod')); + $this->assertTrue($loader->isLoaded('healthy-sibling')); + } + + public function testLoadAllSkipsDependentsOfBrokenModule(): void { + $root = $this->createModulesRoot(); + // 'dependent' requires 'broken-base'; 'broken-base' fails to load (no class + // file). The dependent must be skipped too — booting it without its + // dependency present could fatal. + $dir = $root . '/broken-base'; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'broken-base', + 'version' => '1.0.0', + 'requires_core' => '>=2.0', + 'environment' => 'main', + 'dependencies' => [], + ])); + $this->createModule($root, 'dependent', ['dependencies' => ['broken-base']]); + + $loader = new ModuleLoader(); + $loader->loadAll($root); + + $this->assertFalse($loader->isLoaded('broken-base')); + $this->assertFalse($loader->isLoaded('dependent')); + } + public function testLoadAllSkipsTransitiveDependentsOfMissingDependency(): void { $root = $this->createModulesRoot(); // chain-gamma -> chain-beta -> missing-module (absent). Both gamma and beta diff --git a/tests/Unit/ModuleManagerMigrationsTest.php b/tests/Unit/ModuleManagerMigrationsTest.php index dca946a2..231f00d8 100644 --- a/tests/Unit/ModuleManagerMigrationsTest.php +++ b/tests/Unit/ModuleManagerMigrationsTest.php @@ -49,6 +49,38 @@ final class ModuleManagerMigrationsTest extends TestCase { $this->assertSame('1.0.0', $overrides['install-record']['installed_version'] ?? null); } + public function testInstallsModuleLivingInHashSuffixedDirectory(): void { + // Reproduces the upload flow: the module was placed in `{name}_{hash5}`, + // never a bare `{name}` dir. installModule() must resolve the real directory + // to load the class — otherwise the module unpacks but fails to install. + $name = 'hashinstall'; + $pascal = $this->pascal($name); + $dir = $this->modulesPath . '/' . $name . '_ab123'; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => $name, + 'hash_id' => 'ab123def4567890ab123def4567890cc', + 'version' => '2.1.0', + 'requires_core' => '>=2.0', + 'environment' => 'main', + 'dependencies' => [], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + file_put_contents($dir . '/' . $pascal . "Module.php", + "manager()->installModule($name); + + // Version comes from the manifest in the hashed dir (proves readModuleManifest + // resolved it too), and state is recorded under the canonical name. + $this->assertSame('2.1.0', $this->readOverrides()[$name]['installed_version'] ?? null); + } + // ── uninstallModule() clears installed_version ──────────────────────── public function testUninstallClearsInstalledVersion(): void { @@ -62,6 +94,32 @@ final class ModuleManagerMigrationsTest extends TestCase { $this->assertArrayNotHasKey('installed_version', $overrides['uninstall-clear'] ?? []); } + // ── deleteModule() — removes directory + override ──────────────────── + + public function testDeleteRemovesModuleDirectoryAndOverride(): void { + $this->createModule('delete-me', '1.0.0'); + $this->writeOverrides(['delete-me' => ['installed_version' => '1.0.0']]); + $dir = $this->modulesPath . '/delete-me'; + $this->assertDirectoryExists($dir); + + $this->manager()->deleteModule('delete-me'); + + $this->assertDirectoryDoesNotExist($dir); + $this->assertArrayNotHasKey('delete-me', $this->readOverrides()); + } + + public function testDeleteBlockedByInstalledDependent(): void { + $this->createModuleWithDeps('del-base', '1.0.0', []); + $this->createModuleWithDeps('del-consumer', '1.0.0', ['del-base']); + $this->writeOverrides([ + 'del-base' => ['installed_version' => '1.0.0'], + 'del-consumer' => ['installed_version' => '1.0.0'], + ]); + + $this->expectException(\RuntimeException::class); + $this->manager()->deleteModule('del-base'); + } + // ── updateModule() — fallback to installModule() ────────────────────── public function testUpdateWithNoInstalledVersionFallsBackToInstall(): void { @@ -135,6 +193,108 @@ final class ModuleManagerMigrationsTest extends TestCase { $this->assertSame('3.0.0', $overrides['update-nomir']['installed_version'] ?? null); } + // ── findModuleByHashId() — stable identity lookup (P5 foundation) ───── + + public function testFindModuleByHashId(): void { + $dir = $this->modulesPath . '/id-mod'; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'id-mod', + 'hash_id' => 'abc123def4567890abc123def4567890', + 'version' => '1.0.0', + 'environment' => 'main', + 'dependencies' => [], + 'optional_dependencies' => [], + ])); + + $manager = $this->manager(); + $this->assertSame('id-mod', $manager->findModuleByHashId('abc123def4567890abc123def4567890')); + $this->assertNull($manager->findModuleByHashId('does-not-exist')); + $this->assertNull($manager->findModuleByHashId('')); + } + + // ── migrateLegacyModuleDirs() — bare {name} → {name}_{hash5} ────────── + + public function testMigrateLegacyDirGeneratesHashAndRenames(): void { + // Legacy bare directory with NO hash_id in the manifest. + $dir = $this->modulesPath . '/legacy-mod'; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'legacy-mod', + 'version' => '1.0.0', + 'environment' => 'main', + 'dependencies' => [], + ])); + + $migrated = $this->manager()->migrateLegacyModuleDirs(); + + $this->assertSame(['legacy-mod'], $migrated); + $this->assertDirectoryDoesNotExist($dir, 'bare directory must be gone'); + + $hashed = glob($this->modulesPath . '/legacy-mod_*'); + $this->assertCount(1, $hashed, 'exactly one {name}_{hash5} directory must exist'); + + $meta = json_decode((string) file_get_contents($hashed[0] . '/module.json'), true); + $hash = (string) ($meta['hash_id'] ?? ''); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $hash, 'a 32-hex hash_id must be written'); + $this->assertSame('legacy-mod_' . substr($hash, 0, 5), basename($hashed[0]), 'suffix = first 5 of hash_id'); + } + + public function testMigrateLegacyDirDropsStaleBareDuplicate(): void { + // Both a hashed copy (canonical, active) and a bare dup exist for the same + // module — the bare one must be removed, the hashed one kept untouched. + $hash = 'aa11bb22cc33dd44ee55ff6600778899'; + $hash5 = substr($hash, 0, 5); + + $bare = $this->modulesPath . '/dup-mod'; + mkdir($bare, 0775, true); + file_put_contents($bare . '/module.json', json_encode([ + 'name' => 'dup-mod', 'hash_id' => $hash, 'version' => '1.0.0', + 'environment' => 'main', 'dependencies' => [], + ])); + + $hashed = $this->modulesPath . '/dup-mod_' . $hash5; + mkdir($hashed, 0775, true); + file_put_contents($hashed . '/module.json', json_encode([ + 'name' => 'dup-mod', 'hash_id' => $hash, 'version' => '1.0.0', + 'environment' => 'main', 'dependencies' => [], + ])); + + $migrated = $this->manager()->migrateLegacyModuleDirs(); + + $this->assertSame(['dup-mod'], $migrated); + $this->assertDirectoryDoesNotExist($bare, 'stale bare duplicate must be removed'); + $this->assertDirectoryExists($hashed, 'canonical hashed directory must be kept'); + } + + public function testMigrateLegacyDirLeavesHashedDirsUntouched(): void { + // An already-canonical {name}_{hash5} directory must not be migrated again. + $hash = 'abc123def4567890abc123def4567890'; + $dir = $this->modulesPath . '/already_' . substr($hash, 0, 5); + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'already', 'hash_id' => $hash, 'version' => '1.0.0', + 'environment' => 'main', 'dependencies' => [], + ])); + + $this->assertSame([], $this->manager()->migrateLegacyModuleDirs()); + $this->assertDirectoryExists($dir); + } + + // ── updateModuleFromSource() — bundled path (no network) ────────────── + + public function testUpdateFromSourceBundledRunsMigrationsAndRecordsVersion(): void { + // No `update` block → source is `bundled`: files ship with the panel, so + // updateModuleFromSource() just runs the incremental migrations (no fetch). + $this->createMigratableModule('bundled-upd', '1.2.0', ['1.1.0', '1.2.0']); + $this->writeOverrides(['bundled-upd' => ['installed_version' => '1.0.0']]); + + $this->manager()->updateModuleFromSource('bundled-upd'); + + $this->assertSame(['1.1.0', '1.2.0'], MigrationCallTracker::$calls); + $this->assertSame('1.2.0', $this->readOverrides()['bundled-upd']['installed_version'] ?? null); + } + // ── Helpers ─────────────────────────────────────────────────────────── // ── listModules() dependency diagnostics ──────────────────────────────