Files
XC_VM/tests/Unit/ModuleManagerMigrationsTest.php
T
Divarion_D 4ecb9db739 feat(modules): show modules by their module.json title
The Modules page, dependency warnings and job messages named modules by
`name` — the code identifier (`billing`) that keys config/modules.php, the
class namespace and dependencies. module.json now takes an optional
`title` for people (the store writes its listing name there); the page
shows it with `name` under it, and everything still keys off `name`.
Without a title the name is shown, as before.
2026-10-13 20:39:31 +03:00

859 lines
39 KiB
PHP

<?php
use XcVm\Core\Container\ServiceContainer;
use XcVm\Core\Enum\ModuleState;
use XcVm\Core\Module\ModuleManager;
use XcVm\Core\Module\BaseModule;
use PHPUnit\Framework\TestCase;
use XcVm\Public\Controllers\Admin\Ajax\ModuleAjaxController;
// Static tracker used by generated module migration callables.
class MigrationCallTracker {
public static array $calls = [];
public static function record(string $version): void {
self::$calls[] = $version;
}
public static function reset(): void {
self::$calls = [];
}
}
// Minimal db double for module schema files: it can be told to fail on a given
// statement, which is what makes the update/uninstall failure paths reachable.
class FakeModuleDb {
public string $failOn = '';
public array $statements = [];
public function query(string $sql): bool {
$this->statements[] = $sql;
return $this->failOn === '' || strpos($sql, $this->failOn) === false;
}
}
final class ModuleManagerMigrationsTest extends TestCase {
private string $modulesPath;
private string $overridesPath;
private string $workDir;
private FakeModuleDb $db;
protected function setUp(): void {
$this->workDir = sys_get_temp_dir() . '/xc_vm_mgr_' . bin2hex(random_bytes(6));
$this->modulesPath = $this->workDir . '/modules';
$this->overridesPath = $this->workDir . '/modules.php';
mkdir($this->modulesPath, 0775, true);
$this->db = new FakeModuleDb();
ServiceContainer::getInstance()->set('db', $this->db);
MigrationCallTracker::reset();
}
protected function tearDown(): void {
ServiceContainer::getInstance()->remove('db');
$this->deleteDir($this->workDir);
MigrationCallTracker::reset();
}
// ── installModule() records installed_version ─────────────────────────
public function testInstallRecordsInstalledVersion(): void {
$this->createModule('install-record', '1.0.0');
$manager = $this->manager();
$manager->installModule('install-record');
$overrides = $this->readOverrides();
$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",
"<?php\nnamespace XcVm\\Module\\{$pascal};\n"
. "use XcVm\\Core\\Module\\BaseModule;\n"
. "class {$pascal}Module extends BaseModule {\n"
. "\tpublic function getName(): string { return '{$name}'; }\n"
. "\tpublic function getVersion(): string { return '2.1.0'; }\n"
. "}\n"
);
$this->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 {
$this->createModule('uninstall-clear', '1.0.0');
$this->writeOverrides(['uninstall-clear' => ['installed_version' => '1.0.0']]);
$manager = $this->manager();
$manager->uninstallModule('uninstall-clear');
$overrides = $this->readOverrides();
$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 {
$this->createModule('update-fallback', '2.0.0');
$manager = $this->manager();
$manager->updateModule('update-fallback');
$overrides = $this->readOverrides();
$this->assertSame('2.0.0', $overrides['update-fallback']['installed_version'] ?? null);
}
// ── updateModule() — already up-to-date ──────────────────────────────
public function testUpdateWithSameVersionSkipsMigrations(): void {
$this->createMigratableModule('update-same', '1.0.0', ['1.0.0']);
$this->writeOverrides(['update-same' => ['installed_version' => '1.0.0']]);
$manager = $this->manager();
$manager->updateModule('update-same');
// Migration for 1.0.0 must NOT run — fromVersion == toVersion
$this->assertSame([], MigrationCallTracker::$calls);
}
// ── updateModule() — runs pending migrations in semver order ─────────
public function testUpdateRunsPendingMigrationsInOrder(): void {
$this->createMigratableModule('update-order', '1.3.0', ['0.9.0', '1.1.0', '1.3.0', '1.2.0']);
$this->writeOverrides(['update-order' => ['installed_version' => '1.0.0']]);
$manager = $this->manager();
$manager->updateModule('update-order');
// 0.9.0 <= 1.0.0 → excluded. 1.1.0, 1.2.0, 1.3.0 → ascending semver order.
$this->assertSame(['1.1.0', '1.2.0', '1.3.0'], MigrationCallTracker::$calls);
}
public function testUpdateExcludesMigrationsAboveToVersion(): void {
$this->createMigratableModule('update-cap', '1.1.0', ['1.1.0', '1.2.0', '2.0.0']);
$this->writeOverrides(['update-cap' => ['installed_version' => '1.0.0']]);
$manager = $this->manager();
$manager->updateModule('update-cap');
// Module version is 1.1.0 → migrations > 1.1.0 must be excluded
$this->assertSame(['1.1.0'], MigrationCallTracker::$calls);
}
// ── updateModule() — records new version after migrations ─────────────
public function testUpdateRecordsNewVersionAfterMigrations(): void {
$this->createMigratableModule('update-record', '2.5.0', ['2.0.0', '2.5.0']);
$this->writeOverrides(['update-record' => ['installed_version' => '1.9.0']]);
$manager = $this->manager();
$manager->updateModule('update-record');
$overrides = $this->readOverrides();
$this->assertSame('2.5.0', $overrides['update-record']['installed_version'] ?? null);
}
public function testUpdateWithNoMigrationsStillRecordsNewVersion(): void {
$this->createMigratableModule('update-nomir', '3.0.0', []);
$this->writeOverrides(['update-nomir' => ['installed_version' => '2.0.0']]);
$manager = $this->manager();
$manager->updateModule('update-nomir');
$overrides = $this->readOverrides();
$this->assertSame('3.0.0', $overrides['update-nomir']['installed_version'] ?? null);
}
// ── 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 ──────────────────────────────
public function testModulesAreShownByTitleAndKeyedByName(): void {
$this->createModuleWithDeps('dep-base', '1.0.0', []);
$this->createModuleWithDeps('dep-consumer', '1.0.0', ['dep-base']);
$file = $this->modulesPath . '/dep-base/module.json';
$manifest = json_decode((string) file_get_contents($file), true);
file_put_contents($file, json_encode(['title' => 'Base Module'] + $manifest));
$this->writeOverrides(['dep-base' => ['state' => 'disabled']]);
$byName = $this->modulesByName();
$this->assertSame('Base Module', $byName['dep-base']['title']);
$this->assertSame('dep-consumer', $byName['dep-consumer']['title']); // no title → its name
$this->assertStringContainsString("'Base Module' is not enabled", $byName['dep-consumer']['dependency_warnings'][0]);
$this->assertSame('Base Module', $this->manager()->titleOf('dep-base'));
$rows = array_column(ModuleAjaxController::rows($this->manager()), null, 'name');
$this->assertSame('Base Module', $rows['dep-base']['title']);
}
public function testListModulesFlagsDisabledRequiredDependency(): void {
// dep-consumer requires dep-base, which is present but disabled.
$this->createModuleWithDeps('dep-base', '1.0.0', []);
$this->createModuleWithDeps('dep-consumer', '1.0.0', ['dep-base']);
$this->writeOverrides(['dep-base' => ['state' => 'disabled']]);
$byName = $this->modulesByName();
$this->assertSame([], $byName['dep-base']['dependency_warnings']);
$this->assertCount(1, $byName['dep-consumer']['dependency_warnings']);
$this->assertStringContainsString('dep-base', $byName['dep-consumer']['dependency_warnings'][0]);
$this->assertStringContainsString('not enabled', $byName['dep-consumer']['dependency_warnings'][0]);
}
public function testListModulesFlagsMissingRequiredDependency(): void {
$this->createModuleWithDeps('needs-absent', '1.0.0', ['ghost-module']);
$byName = $this->modulesByName();
$this->assertCount(1, $byName['needs-absent']['dependency_warnings']);
$this->assertStringContainsString('missing', $byName['needs-absent']['dependency_warnings'][0]);
}
public function testListModulesNoWarningWhenDependencyEnabled(): void {
$this->createModuleWithDeps('ok-base', '1.0.0', []);
$this->createModuleWithDeps('ok-consumer', '1.0.0', ['ok-base']);
$byName = $this->modulesByName();
$this->assertSame([], $byName['ok-consumer']['dependency_warnings']);
}
// ── install keeps the admin's on/off decision ─────────────────────────
public function testReinstallKeepsAModuleTheAdminDisabled(): void {
// A store update lands in installModule(), which used to finish with an
// unconditional Enabled — silently switching a disabled module back on.
$this->createModule('keep-off', '1.0.0');
$this->writeOverrides(['keep-off' => ['state' => 'disabled', 'installed_version' => '1.0.0']]);
$this->manager()->installModule('keep-off');
$overrides = $this->readOverrides();
$this->assertSame('disabled', $overrides['keep-off']['state'] ?? null);
$this->assertSame('1.0.0', $overrides['keep-off']['installed_version'] ?? null);
}
public function testFirstInstallEnablesTheModule(): void {
$this->createModule('turn-on', '1.0.0');
$this->manager()->installModule('turn-on');
// Enabled is the clean default, recorded by the ABSENCE of a state key.
$this->assertArrayNotHasKey('state', $this->readOverrides()['turn-on']);
}
public function testReinstallRestoresDisabledEvenWithADependent(): void {
// Restoring Disabled is a lifecycle transition, not an admin "disable",
// so the dependents guard must not veto it and fail the install.
$this->createModuleWithDeps('dep-off', '1.0.0', []);
$this->createModuleWithDeps('dep-user', '1.0.0', ['dep-off']);
$this->writeOverrides([
'dep-off' => ['state' => 'disabled', 'installed_version' => '1.0.0'],
'dep-user' => ['installed_version' => '1.0.0'],
]);
$this->manager()->installModule('dep-off');
$this->assertSame('disabled', $this->readOverrides()['dep-off']['state'] ?? null);
}
// ── update/uninstall failure containment ──────────────────────────────
public function testUpdateKeepsWatermarkAtLastCompletedVersionOnFailure(): void {
// Deltas 1.1.0 and 1.2.0 pending; the second one fails. The recorded
// version must stop at 1.1.0 so a retry resumes instead of replaying it.
$this->createModule('resume-mod', '1.2.0');
$dir = $this->modulesPath . '/resume-mod/migrations';
mkdir($dir, 0775, true);
file_put_contents($dir . '/1.1.0.sql', 'SELECT 1;');
file_put_contents($dir . '/1.2.0.sql', 'BOOM;');
$this->writeOverrides(['resume-mod' => ['installed_version' => '1.0.0']]);
$this->db->failOn = 'BOOM';
try {
$this->manager()->updateModule('resume-mod');
$this->fail('expected the failing delta to throw');
} catch (RuntimeException $e) {
// expected
}
$overrides = $this->readOverrides();
$this->assertSame('1.1.0', $overrides['resume-mod']['installed_version'] ?? null);
$this->assertSame('failed', $overrides['resume-mod']['state'] ?? null);
// The cause put right, the update runs to its end: the module is no
// longer Failed. It stayed so, updated and not loaded.
$this->db->failOn = '';
$this->manager()->updateModule('resume-mod');
$this->assertSame(['installed_version' => '1.2.0'], array_intersect_key($this->readOverrides()['resume-mod'], ['installed_version' => 1, 'state' => 1]));
}
public function testUpdateRecordsTargetVersionWhenEveryDeltaApplies(): void {
$this->createModule('resume-ok', '1.2.0');
$dir = $this->modulesPath . '/resume-ok/migrations';
mkdir($dir, 0775, true);
file_put_contents($dir . '/1.1.0.sql', 'SELECT 1;');
file_put_contents($dir . '/1.2.0.sql', 'SELECT 2;');
$this->writeOverrides(['resume-ok' => ['installed_version' => '1.0.0']]);
$this->manager()->updateModule('resume-ok');
$overrides = $this->readOverrides();
$this->assertSame('1.2.0', $overrides['resume-ok']['installed_version'] ?? null);
$this->assertArrayNotHasKey('state', $overrides['resume-ok']);
}
public function testUninstallMarksModuleFailedWhenTeardownThrows(): void {
$this->createModule('teardown-mod', '1.0.0');
file_put_contents($this->modulesPath . '/teardown-mod/database_drop.sql', 'BOOM;');
$this->writeOverrides(['teardown-mod' => ['installed_version' => '1.0.0']]);
$this->db->failOn = 'BOOM';
try {
$this->manager()->uninstallModule('teardown-mod');
$this->fail('expected the failing teardown to throw');
} catch (RuntimeException $e) {
// expected
}
$overrides = $this->readOverrides();
$this->assertSame('failed', $overrides['teardown-mod']['state'] ?? null);
// Still recorded as installed — its tables were not dropped.
$this->assertSame('1.0.0', $overrides['teardown-mod']['installed_version'] ?? null);
}
// ── one directory per module ──────────────────────────────────────────
public function testUploadPlacesModuleInHashedDirAndDropsLegacyBareCopy(): void {
// A stale bare `{name}` copy must not survive beside the canonical one —
// two dirs for one module is what made the platform flow lose track of it.
$legacy = $this->modulesPath . '/upl-mod';
mkdir($legacy, 0775, true);
file_put_contents($legacy . '/module.json', json_encode(['name' => 'upl-mod', 'version' => '0.9.0']));
try {
$this->manager()->uploadAndInstall($this->makeModuleTar('upl-mod', '1.0.0', str_repeat('ab12', 8)));
} catch (Error $e) {
// uploadAndInstall ends by distributing to load balancers through the
// xcvm_core extension, which is absent here. Extraction, placement and
// install have all already run by then — that is what this asserts.
$this->assertStringContainsString('XC_VM', $e->getMessage());
}
$this->assertDirectoryExists($this->modulesPath . '/upl-mod_ab12a');
$this->assertDirectoryDoesNotExist($legacy);
$this->assertSame('1.0.0', $this->readOverrides()['upl-mod']['installed_version'] ?? null);
}
public function testUploadOfARenamedModuleTakesOverTheOldInstall(): void {
// Same hash_id, new name: the old directory must go (else both boot) and
// its state must follow (else the module loses installed_version/state).
$hash = str_repeat('ef56', 8);
$old = $this->modulesPath . '/old-name_ef56e';
mkdir($old, 0775, true);
file_put_contents($old . '/module.json', json_encode(['name' => 'old-name', 'hash_id' => $hash, 'version' => '1.0.0']));
$this->writeOverrides(['old-name' => ['state' => 'disabled', 'installed_version' => '1.0.0']]);
try {
$this->manager()->uploadAndInstall($this->makeModuleTar('new-name', '1.1.0', $hash));
} catch (Error $e) {
$this->assertStringContainsString('XC_VM', $e->getMessage()); // LB fan-out, see above
}
$overrides = $this->readOverrides();
$this->assertDirectoryExists($this->modulesPath . '/new-name_ef56e');
$this->assertDirectoryDoesNotExist($old);
$this->assertArrayNotHasKey('old-name', $overrides);
$this->assertSame('disabled', $overrides['new-name']['state'] ?? null);
$this->assertSame('1.1.0', $overrides['new-name']['installed_version'] ?? null);
}
// ── requires_core ─────────────────────────────────────────────────────
public function testUploadRefusesAModuleForAnotherCoreAndKeepsTheInstalledCopy(): void {
$this->createModule('core-mod', '1.0.0');
try {
$this->manager()->uploadAndInstall($this->makeModuleTar('core-mod', '2.0.0', str_repeat('cd34', 8), '>=99.0'));
$this->fail('An upload for another core must be refused.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('needs core >=99.0', $e->getMessage());
}
$this->assertDirectoryExists($this->modulesPath . '/core-mod');
$this->assertSame('1.0.0', json_decode((string) file_get_contents($this->modulesPath . '/core-mod/module.json'), true)['version']);
$this->assertDirectoryDoesNotExist($this->modulesPath . '/core-mod_cd34c');
}
public function testARefusedCopyInsideTheModulesDirectoryIsRemoved(): void {
// The platform extension extracts straight into the modules directory.
$pulled = $this->modulesPath . '/pulled-mod';
mkdir($pulled, 0775, true);
file_put_contents($pulled . '/module.json', json_encode(['name' => 'pulled-mod', 'version' => '2.0.0', 'requires_core' => '>=99.0']));
$place = new ReflectionMethod(ModuleManager::class, 'placeModuleFiles');
try {
$place->invoke($this->manager(), $pulled);
$this->fail('A module for another core must be refused.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('needs core', $e->getMessage());
}
$this->assertDirectoryDoesNotExist($pulled);
}
public function testAGitUpdateMustBeTheSameModuleAndFitThisCore(): void {
$check = new ReflectionMethod(ModuleManager::class, 'assertSameModuleForThisCore');
$update = $this->workDir . '/update';
mkdir($update, 0775, true);
$write = function (array $meta) use ($update): void {
file_put_contents($update . '/module.json', json_encode($meta));
};
$installed = array('name' => 'git-mod', 'hash_id' => str_repeat('a', 32));
$write(array('name' => 'git-mod', 'hash_id' => str_repeat('a', 32), 'requires_core' => '>=2.0'));
$check->invoke($this->manager(), $update, $installed, 'git-mod');
$write(array('name' => 'git-mod', 'hash_id' => str_repeat('b', 32), 'requires_core' => '>=2.0'));
try {
$check->invoke($this->manager(), $update, $installed, 'git-mod');
$this->fail('Another module must be refused.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('hash_id mismatch', $e->getMessage());
}
$write(array('name' => 'git-mod', 'hash_id' => str_repeat('a', 32), 'requires_core' => '>=99.0'));
try {
$check->invoke($this->manager(), $update, $installed, 'git-mod');
$this->fail('A release for another core must be refused.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('needs core >=99.0', $e->getMessage());
}
$this->assertDirectoryExists($update, 'an update outside the modules directory is left to its caller');
}
public function testListModulesFlagsAModuleForAnotherCore(): void {
$this->createModule('core-ok', '1.0.0');
$this->createModule('core-ahead', '1.0.0');
$manifest = json_decode((string) file_get_contents($this->modulesPath . '/core-ahead/module.json'), true);
$manifest['requires_core'] = '>=99.0';
file_put_contents($this->modulesPath . '/core-ahead/module.json', json_encode($manifest));
$byName = $this->modulesByName();
$this->assertSame([], $byName['core-ok']['dependency_warnings']);
$this->assertCount(1, $byName['core-ahead']['dependency_warnings']);
$this->assertStringContainsString('needs core >=99.0', $byName['core-ahead']['dependency_warnings'][0]);
}
public function testListModulesShowsOneRowWhenBareAndHashedCopiesCoexist(): void {
// The platform flow used to extract into a bare `{name}` dir beside the
// canonical `{name}_{hash5}` one; the table then listed the module twice.
$this->createModuleWithDeps('dupe-mod', '1.0.0', []);
$hashed = $this->modulesPath . '/dupe-mod_ab123';
mkdir($hashed, 0775, true);
copy($this->modulesPath . '/dupe-mod/module.json', $hashed . '/module.json');
$names = array_column($this->manager()->listModules(), 'name');
$this->assertSame(['dupe-mod'], $names);
}
// ── setState() disable guard ──────────────────────────────────────────
public function testDisableBlockedByWorkingDependent(): void {
$this->createModuleWithDeps('guard-base', '1.0.0', []);
$this->createModuleWithDeps('guard-consumer', '1.0.0', ['guard-base']);
$this->writeOverrides([
'guard-base' => ['installed_version' => '1.0.0'],
'guard-consumer' => ['installed_version' => '1.0.0'],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('guard-consumer');
$this->manager()->setState('guard-base', ModuleState::Disabled);
}
public function testDisableAllowedWhenDependentIsAlreadyBrokenByDisabledDependency(): void {
// Chain: top -> middle -> bottom. bottom is disabled, so the loader already
// prunes middle AND top. Disabling middle breaks nothing and must succeed —
// the guard used to count top as an enabled dependent and refuse.
$this->createModuleWithDeps('chain-bottom', '1.0.0', []);
$this->createModuleWithDeps('chain-middle', '1.0.0', ['chain-bottom']);
$this->createModuleWithDeps('chain-top', '1.0.0', ['chain-middle']);
$this->writeOverrides([
'chain-bottom' => ['state' => 'disabled', 'installed_version' => '1.0.0'],
'chain-middle' => ['installed_version' => '1.0.0'],
'chain-top' => ['installed_version' => '1.0.0'],
]);
$this->manager()->setState('chain-middle', ModuleState::Disabled);
$this->assertSame('disabled', $this->readOverrides()['chain-middle']['state'] ?? null);
}
public function testDisableAllowedWhenDependentIsItselfDisabled(): void {
$this->createModuleWithDeps('solo-base', '1.0.0', []);
$this->createModuleWithDeps('solo-consumer', '1.0.0', ['solo-base']);
$this->writeOverrides([
'solo-base' => ['installed_version' => '1.0.0'],
'solo-consumer' => ['state' => 'disabled', 'installed_version' => '1.0.0'],
]);
$this->manager()->setState('solo-base', ModuleState::Disabled);
$this->assertSame('disabled', $this->readOverrides()['solo-base']['state'] ?? null);
}
// ── two writers of config/modules.php ─────────────────────────────────
/**
* A manager writes what it changed since it read the file onto the file
* as it is now. It wrote back the whole file as it had read it, so of two
* processes changing it at once the second undid the first's change.
*/
public function testAManagerWritesOnlyWhatItChangedSinceItReadTheFile(): void {
$this->writeOverrides(['one' => ['installed_version' => '1.0.0'], 'two' => ['installed_version' => '1.0.0']]);
$first = $this->manager();
$read = new ReflectionMethod($first, 'readOverrides');
$write = new ReflectionMethod($first, 'writeOverrides');
$mine = $read->invoke($first);
// Another process, meanwhile: module two is updated and module three installed.
$second = $this->manager();
(new ReflectionMethod($second, 'readOverrides'))->invoke($second);
(new ReflectionMethod($second, 'writeOverrides'))->invoke($second, ['one' => ['installed_version' => '1.0.0'], 'two' => ['installed_version' => '2.0.0'], 'three' => ['installed_version' => '1.0.0']]);
$mine['one']['state'] = 'disabled';
$write->invoke($first, $mine);
$this->assertSame(
['one' => ['installed_version' => '1.0.0', 'state' => 'disabled'], 'three' => ['installed_version' => '1.0.0'], 'two' => ['installed_version' => '2.0.0']],
$this->readOverrides()
);
// Field by field within one module, and a removal is a removal.
$this->assertSame(
['a' => ['installed_version' => '2.0.0', 'state' => 'disabled']],
ModuleManager::mergeOverrides(['a' => ['installed_version' => '1.0.0'], 'b' => ['x' => 1]], ['a' => ['installed_version' => '1.0.0', 'state' => 'disabled']], ['a' => ['installed_version' => '2.0.0'], 'b' => ['x' => 1, 'y' => 2]])
);
$this->assertSame(['a' => ['v' => 1]], ModuleManager::mergeOverrides(['a' => ['v' => 1, 'state' => 'failed']], ['a' => ['v' => 1]], ['a' => ['v' => 1, 'state' => 'failed']]), 'a field taken away');
$this->assertSame(['a' => ['v' => 1]], ModuleManager::mergeOverrides([], ['a' => ['v' => 1]], []), 'a first entry');
$this->assertSame(['other' => []], ModuleManager::mergeOverrides(['a' => ['v' => 1]], ['a' => ['v' => 1]], ['other' => []]), 'nothing of mine changed: theirs as it is');
}
private function manager(): ModuleManager {
return new ModuleManager($this->modulesPath, $this->overridesPath, ServiceContainer::getInstance());
}
/** @return array<string, array> listModules() keyed by module name. */
private function modulesByName(): array {
$byName = [];
foreach ($this->manager()->listModules() as $module) {
$byName[$module['name']] = $module;
}
return $byName;
}
/** Build a .tar holding one module under a `{name}/` prefix; returns its path. */
private function makeModuleTar(string $name, string $version, string $hashId, string $requiresCore = '>=2.0'): string {
$pascal = $this->pascal($name);
$manifest = $this->manifest($name, $version);
$manifest['hash_id'] = $hashId;
$manifest['requires_core'] = $requiresCore;
$tarPath = $this->workDir . '/' . $name . '.tar';
@unlink($tarPath);
$tar = new PharData($tarPath);
$tar->addFromString(
$name . '/module.json',
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
$tar->addFromString($name . '/' . $pascal . 'Module.php',
"<?php\n"
. "namespace XcVm\Module\\{$pascal};\n"
. "use XcVm\Core\Module\BaseModule;\n"
. "class {$pascal}Module extends BaseModule {\n"
. "\tpublic function getName(): string { return '{$name}'; }\n"
. "\tpublic function getVersion(): string { return '{$version}'; }\n"
. "}\n"
);
unset($tar);
return $tarPath;
}
/** Create a plain module whose manifest declares the given required dependencies. */
private function createModuleWithDeps(string $name, string $version, array $dependencies): void {
$dir = $this->modulesPath . '/' . $name;
$pascal = $this->pascal($name);
$className = $pascal . 'Module';
$namespace = 'XcVm\\Module\\' . $pascal;
mkdir($dir, 0775, true);
$manifest = $this->manifest($name, $version);
$manifest['dependencies'] = $dependencies;
file_put_contents(
$dir . '/module.json',
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
file_put_contents($dir . '/' . $className . '.php',
"<?php\n"
. "namespace {$namespace};\n"
. "use XcVm\Core\Module\BaseModule;\n"
. "class {$className} extends BaseModule {\n"
. "\tpublic function getName(): string { return '{$name}'; }\n"
. "\tpublic function getVersion(): string { return '{$version}'; }\n"
. "}\n"
);
}
/** Create a plain module (no migrations) at the given version. */
private function createModule(string $name, string $version): void {
$dir = $this->modulesPath . '/' . $name;
$pascal = $this->pascal($name);
$className = $pascal . 'Module';
$namespace = 'XcVm\\Module\\' . $pascal;
mkdir($dir, 0775, true);
file_put_contents(
$dir . '/module.json',
json_encode($this->manifest($name, $version), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
file_put_contents($dir . '/' . $className . '.php',
"<?php\n"
. "namespace {$namespace};\n"
. "use XcVm\Core\Module\BaseModule;\n"
. "class {$className} extends BaseModule {\n"
. "\tpublic function getName(): string { return '{$name}'; }\n"
. "\tpublic function getVersion(): string { return '{$version}'; }\n"
. "}\n"
);
}
/**
* Create a module that tracks migration calls via MigrationCallTracker.
*
* @param string $name Module name (kebab-case).
* @param string $version Module version (reported by getVersion()).
* @param string[] $migrationVersions Version strings to include as migration keys.
*/
private function createMigratableModule(string $name, string $version, array $migrationVersions): void {
$dir = $this->modulesPath . '/' . $name;
$pascal = $this->pascal($name);
$className = $pascal . 'Module';
$namespace = 'XcVm\\Module\\' . $pascal;
mkdir($dir, 0775, true);
file_put_contents(
$dir . '/module.json',
json_encode($this->manifest($name, $version), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
$migLines = '';
foreach ($migrationVersions as $ver) {
$migLines .= "\t\t\t'{$ver}' => function(\$c) { \\MigrationCallTracker::record('{$ver}'); },\n";
}
file_put_contents($dir . '/' . $className . '.php',
"<?php\n"
. "namespace {$namespace};\n"
. "use XcVm\Core\Module\BaseModule;\n"
. "class {$className} extends BaseModule {\n"
. "\tpublic function getName(): string { return '{$name}'; }\n"
. "\tpublic function getVersion(): string { return '{$version}'; }\n"
. "\tpublic function getMigrations(): array {\n"
. "\t\treturn [\n"
. $migLines
. "\t\t];\n"
. "\t}\n"
. "}\n"
);
}
private function manifest(string $name, string $version): array {
return [
'name' => $name,
'description' => 'migration test module',
'version' => $version,
'requires_core' => '>=2.0',
'environment' => 'main',
'dependencies' => [],
'optional_dependencies' => [],
'has_navbar' => false,
'has_settings' => false,
'priority' => 0,
];
}
private function writeOverrides(array $overrides): void {
$content = "<?php\n\nreturn " . var_export($overrides, true) . ";\n";
file_put_contents($this->overridesPath, $content);
}
private function readOverrides(): array {
if (!file_exists($this->overridesPath)) {
return [];
}
$data = require $this->overridesPath;
return is_array($data) ? $data : [];
}
private function pascal(string $name): string {
return implode('', array_map('ucfirst', explode('-', $name)));
}
private function deleteDir(string $path): void {
if (!file_exists($path)) {
return;
}
if (is_file($path) || is_link($path)) {
@unlink($path);
return;
}
foreach (scandir($path) ?: [] as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$this->deleteDir($path . '/' . $item);
}
@rmdir($path);
}
}