2026-06-15 18:07:50 +03:00
<? php
2026-06-24 22:13:35 +03:00
use XcVm\Core\Container\ServiceContainer ;
2026-09-22 16:21:51 +03:00
use XcVm\Core\Enum\ModuleState ;
2026-06-24 22:05:10 +03:00
use XcVm\Core\Module\ModuleManager ;
use XcVm\Core\Module\BaseModule ;
2026-06-15 18:07:50 +03:00
use PHPUnit\Framework\TestCase ;
2026-10-13 20:39:31 +03:00
use XcVm\Public\Controllers\Admin\Ajax\ModuleAjaxController ;
2026-06-15 18:07:50 +03:00
// 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 = [];
}
}
2026-09-22 16:21:51 +03:00
// 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 ;
}
}
2026-06-15 18:07:50 +03:00
final class ModuleManagerMigrationsTest extends TestCase {
private string $modulesPath ;
private string $overridesPath ;
private string $workDir ;
2026-09-22 16:21:51 +03:00
private FakeModuleDb $db ;
2026-06-15 18:07:50 +03:00
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 );
2026-09-22 16:21:51 +03:00
$this -> db = new FakeModuleDb ();
ServiceContainer :: getInstance () -> set ( 'db' , $this -> db );
2026-06-15 18:07:50 +03:00
MigrationCallTracker :: reset ();
}
protected function tearDown () : void {
2026-09-22 16:21:51 +03:00
ServiceContainer :: getInstance () -> remove ( 'db' );
2026-06-15 18:07:50 +03:00
$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 );
}
2026-07-03 20:12:01 +03:00
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 \n namespace XcVm \\ Module \\ { $pascal } ; \n "
. "use XcVm \\ Core \\ Module \\ BaseModule; \n "
. "class { $pascal } Module extends BaseModule { \n "
. " \t public function getName(): string { return ' { $name } '; } \n "
. " \t public 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 );
}
2026-06-15 18:07:50 +03:00
// ── 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' ] ?? []);
}
2026-07-03 20:12:01 +03:00
// ── 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' );
}
2026-06-15 18:07:50 +03:00
// ── 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 );
}
2026-07-03 20:12:01 +03:00
// ── 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 );
}
2026-06-15 18:07:50 +03:00
// ── Helpers ───────────────────────────────────────────────────────────
2026-06-30 22:41:46 +03:00
// ── listModules() dependency diagnostics ──────────────────────────────
2026-10-13 20:39:31 +03:00
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' ]);
}
2026-06-30 22:41:46 +03:00
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' ]);
}
2026-09-22 20:56:43 +03:00
// ── 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 );
}
2026-09-22 16:21:51 +03:00
// ── 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 );
2026-10-10 16:13:41 +00:00
// 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 ]));
2026-09-22 16:21:51 +03:00
}
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 );
}
2026-10-10 16:54:18 +03:00
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 );
}
2026-10-04 11:59:43 +03:00
// ── 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 ]);
}
2026-09-22 16:21:51 +03:00
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 );
}
2026-10-10 18:15:12 +00:00
// ── 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' );
}
2026-06-15 18:07:50 +03:00
private function manager () : ModuleManager {
return new ModuleManager ( $this -> modulesPath , $this -> overridesPath , ServiceContainer :: getInstance ());
}
2026-06-30 22:41:46 +03:00
/** @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 ;
}
2026-09-22 16:21:51 +03:00
/** Build a .tar holding one module under a `{name}/` prefix; returns its path. */
2026-10-04 11:59:43 +03:00
private function makeModuleTar ( string $name , string $version , string $hashId , string $requiresCore = '>=2.0' ) : string {
2026-09-22 16:21:51 +03:00
$pascal = $this -> pascal ( $name );
$manifest = $this -> manifest ( $name , $version );
$manifest [ 'hash_id' ] = $hashId ;
2026-10-04 11:59:43 +03:00
$manifest [ 'requires_core' ] = $requiresCore ;
2026-09-22 16:21:51 +03:00
$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 "
. " \t public function getName(): string { return ' { $name } '; } \n "
. " \t public function getVersion(): string { return ' { $version } '; } \n "
. "} \n "
);
unset ( $tar );
return $tarPath ;
}
2026-06-30 22:41:46 +03:00
/** 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 "
. " \t public function getName(): string { return ' { $name } '; } \n "
. " \t public function getVersion(): string { return ' { $version } '; } \n "
. "} \n "
);
}
2026-06-15 18:07:50 +03:00
/** 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 "
2026-06-24 22:05:10 +03:00
. "use XcVm\Core\Module\BaseModule; \n "
2026-06-15 18:07:50 +03:00
. "class { $className } extends BaseModule { \n "
. " \t public function getName(): string { return ' { $name } '; } \n "
. " \t public 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 "
2026-06-24 22:05:10 +03:00
. "use XcVm\Core\Module\BaseModule; \n "
2026-06-15 18:07:50 +03:00
. "class { $className } extends BaseModule { \n "
. " \t public function getName(): string { return ' { $name } '; } \n "
. " \t public function getVersion(): string { return ' { $version } '; } \n "
. " \t public function getMigrations(): array { \n "
. " \t\t return [ \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\n return " . 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 );
}
}