diff --git a/application/utils.inc.php b/application/utils.inc.php index f14d2f0fd..3eda857d9 100644 --- a/application/utils.inc.php +++ b/application/utils.inc.php @@ -1343,13 +1343,23 @@ class utils return APPROOT . 'env-' . MetaModel::GetEnvironment() . '/'; } + /** + * @return string A path to the folder into which data can be written + * @internal + * @since N°6097 2.7.10 3.0.4 3.1.1 + */ + public static function GetDataPath(): string + { + return APPROOT.'data/'; + } + /** * @return string A path to a folder into which any module can store cache data * The corresponding folder is created or cleaned upon code compilation */ public static function GetCachePath() { - return APPROOT.'data/cache-'.MetaModel::GetEnvironment().'/'; + return static::GetDataPath().'cache-'.MetaModel::GetEnvironment().'/'; } /** diff --git a/core/cmdbsource.class.inc.php b/core/cmdbsource.class.inc.php index db95d3af7..a90ee7cce 100644 --- a/core/cmdbsource.class.inc.php +++ b/core/cmdbsource.class.inc.php @@ -430,6 +430,7 @@ class CMDBSource { self::$m_sDBName = ''; } + self::_TablesInfoCacheReset(); // reset the table info cache! } public static function CreateTable($sQuery) diff --git a/tests/php-unit-tests/composer.json b/tests/php-unit-tests/composer.json index 61b516347..fef0397e9 100644 --- a/tests/php-unit-tests/composer.json +++ b/tests/php-unit-tests/composer.json @@ -2,5 +2,12 @@ "require-dev": { "phpunit/phpunit": "^8.5.23", "sempro/phpunit-pretty-print": "^1.4" + }, + "autoload": { + "psr-4": { + "Combodo\\iTop\\Test\\UnitTest\\": "src/BaseTestCase/", + "Combodo\\iTop\\Test\\UnitTest\\Hook\\": "src/Hook/", + "Combodo\\iTop\\Test\\UnitTest\\Service\\": "src/Service/" + } } } diff --git a/tests/php-unit-tests/src/BaseTestCase/ItopCustomDatamodelTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopCustomDatamodelTestCase.php new file mode 100644 index 000000000..91c335736 --- /dev/null +++ b/tests/php-unit-tests/src/BaseTestCase/ItopCustomDatamodelTestCase.php @@ -0,0 +1,200 @@ +setRunClassInSeparateProcess(true); + } + + /** + * @return string Abs path to the XML delta to use for the tests of that class + */ + abstract public function GetDatamodelDeltaAbsPath(): string; + + /** + * @inheritDoc + */ + protected function LoadRequiredItopFiles(): void + { + parent::LoadRequiredItopFiles(); + + $this->RequireOnceItopFile('setup/setuputils.class.inc.php'); + $this->RequireOnceItopFile('setup/runtimeenv.class.inc.php'); + } + + /** + * @return string Environment used as a base (conf. file, modules, DB, ...) to prepare the test environment + */ + protected function GetSourceEnvironment(): string + { + return 'production'; + } + + /** + * @inheritDoc + * @warning This should ONLY be overloaded if your test case XML deltas are NOT compatible with the others, as it will create / compile another environment, increasing the global testing time. + */ + public function GetTestEnvironment(): string + { + return 'php-unit-tests'; + } + + /** + * @return string Absolute path to the {@see \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase::GetTestEnvironment()} folder + */ + final private function GetTestEnvironmentFolderAbsPath(): string + { + return APPROOT.'env-'.$this->GetTestEnvironment().'/'; + } + + /** + * Mark {@see \Combodo\iTop\Test\UnitTest\ItopDataTestCase::GetTestEnvironment()} as ready (compiled) + * + * @return void + */ + final private function MarkEnvironmentReady(): void + { + if (false === $this->IsEnvironmentReady()) { + touch(static::GetTestEnvironmentFolderAbsPath()); + } + } + + /** + * @return bool True if the {@see \Combodo\iTop\Test\UnitTest\ItopDataTestCase::GetTestEnvironment()} is ready (compiled, but not started) + * + * @details Having the environment ready means that it has been compiled for this global tests run, not that it is a relic from a previous global tests run + */ + final private function IsEnvironmentReady(): bool + { + // As these test cases run in separate processes, the best way we found to let know a process if its environment was already prepared for **this run** was to compare the modification times of: + // - its own env- folder + // - a file generated at the beginning of the global test run {@see \Combodo\iTop\Test\UnitTest\Hook\TestsRunStartHook} + $sRunStartedFilePath = TestsRunStartHook::GetRunStartedFileAbsPath(); + $sEnvFolderPath = static::GetTestEnvironmentFolderAbsPath(); + + clearstatcache(); + if (false === file_exists($sRunStartedFilePath) || false === file_exists($sEnvFolderPath)) { + return false; + } + + $iRunStartedFileModificationTime = filemtime($sRunStartedFilePath); + $iEnvFolderModificationTime = filemtime($sEnvFolderPath); + + return $iEnvFolderModificationTime >= $iRunStartedFileModificationTime; + } + + /** + * @inheritDoc + */ + protected function PrepareEnvironment(): void + { + $sSourceEnv = $this->GetSourceEnvironment(); + $sTestEnv = $this->GetTestEnvironment(); + + // Check if test env. is already set and only prepare it if it's not up-to-date + // + // Note: To improve performances, we compile all XML deltas from test cases derived from this class and make a single environment where everything will be ran at once. + // This requires XML deltas to be compatible, but it is a known and accepted trade-off. See PR #457 + if (false === $this->IsEnvironmentReady()) { + //---------------------------------------------------- + // Clear any previous "$sTestEnv" environment + //---------------------------------------------------- + + // - Configuration file + $sConfFile = utils::GetConfigFilePath($sTestEnv); + $sConfFolder = dirname($sConfFile); + if (is_file($sConfFile)) { + chmod($sConfFile, 0777); + SetupUtils::tidydir($sConfFolder); + } + + // - Datamodel delta files + // - Cache folder + // - Compiled folder + // We don't need to clean them as they are already by the compilation + + // - Drop database + // We don't do that now, it will be done before re-creating the DB, once the metamodel is started + + //---------------------------------------------------- + // Prepare "$sTestEnv" environment + //---------------------------------------------------- + + // All the following is greatly inspired by the toolkit's sandbox script + // - Prepare config file + $oSourceConf = new Config(utils::GetConfigFilePath($sSourceEnv)); + if ($oSourceConf->Get('source_dir') === '') { + throw new Exception('Missing entry source_dir from the config file'); + } + + $oTestConfig = clone($oSourceConf); + $oTestConfig->ChangeModulesPath($sSourceEnv, $sTestEnv); + // - Switch DB name to a dedicated one so we don't mess with the original one + $sTestEnvSanitizedForDBName = preg_replace('/[^\d\w]/', '', $sTestEnv); + $oTestConfig->Set('db_name', $oTestConfig->Get('db_name').'_'.$sTestEnvSanitizedForDBName); + + // - Compile env. based on the existing 'production' env. + $oEnvironment = new UnitTestRunTimeEnvironment($sTestEnv); + $oEnvironment->WriteConfigFileSafe($oTestConfig); + $oEnvironment->CompileFrom($sSourceEnv, false); + + // - Force re-creating a fresh DB + CMDBSource::InitFromConfig($oTestConfig); + if (CMDBSource::IsDB($oTestConfig->Get('db_name'))) { + CMDBSource::DropDB(); + } + CMDBSource::CreateDB($oTestConfig->Get('db_name')); + MetaModel::Startup($sConfFile, false /* $bModelOnly */, true /* $bAllowCache */, false /* $bTraceSourceFiles */, $sTestEnv); + + $this->MarkEnvironmentReady(); + $this->debug('Preparation of custom environment "'.$sTestEnv.'" done.'); + } + + parent::PrepareEnvironment(); + } +} diff --git a/tests/php-unit-tests/ItopDataTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php similarity index 94% rename from tests/php-unit-tests/ItopDataTestCase.php rename to tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php index 9f06e3f7b..11fed6899 100644 --- a/tests/php-unit-tests/ItopDataTestCase.php +++ b/tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php @@ -1,21 +1,8 @@ -// +/* + * @copyright Copyright (C) 2010-2023 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ namespace Combodo\iTop\Test\UnitTest; @@ -29,6 +16,7 @@ namespace Combodo\iTop\Test\UnitTest; use ArchivedObjectException; use CMDBObject; use CMDBSource; +use Config; use Contact; use DBObject; use DBObjectSet; @@ -43,10 +31,12 @@ use lnkFunctionalCIToTicket; use MetaModel; use Person; use Server; +use SetupUtils; use TagSetFieldData; use Ticket; use URP_UserProfile; use User; +use utils; use VirtualHost; use VirtualMachine; use XMLDataLoader; @@ -57,6 +47,8 @@ define('TAG_CLASS', 'FAQ'); define('TAG_ATTCODE', 'domains'); /** + * Class ItopDataTestCase + * * Helper class to extend for tests needing access to iTop's metamodel * * **⚠ Warning** Each class extending this one needs to add the following annotations : @@ -68,12 +60,16 @@ define('TAG_ATTCODE', 'domains'); * @since 2.7.7 3.0.1 3.1.0 N°4624 processIsolation is disabled by default and must be enabled in each test needing it (basically all tests using * iTop datamodel) */ -class ItopDataTestCase extends ItopTestCase +abstract class ItopDataTestCase extends ItopTestCase { private $iTestOrgId; // For cleanup private $aCreatedObjects = array(); + /** + * @var string Default environment to use for test cases + */ + const DEFAULT_TEST_ENVIRONMENT = 'production'; const USE_TRANSACTION = true; const CREATE_TEST_ORG = false; @@ -83,11 +79,8 @@ class ItopDataTestCase extends ItopTestCase protected function setUp(): void { parent::setUp(); - $this->RequireOnceItopFile('application/utils.inc.php'); - $sEnv = 'production'; - $sConfigFile = APPCONF.$sEnv.'/'.ITOP_CONFIG_FILE; - MetaModel::Startup($sConfigFile, false /* $bModelOnly */, true /* $bAllowCache */, false /* $bTraceSourceFiles */, $sEnv); + $this->PrepareEnvironment(); if (static::USE_TRANSACTION) { @@ -129,6 +122,52 @@ class ItopDataTestCase extends ItopTestCase parent::tearDown(); } + /** + * @inheritDoc + */ + protected function LoadRequiredItopFiles(): void + { + parent::LoadRequiredItopFiles(); + + $this->RequireOnceItopFile('application/utils.inc.php'); + } + + /** + * @return string Environment the test will run in + * @since 2.7.9 3.0.4 3.1.0 + */ + protected function GetTestEnvironment(): string + { + return self::DEFAULT_TEST_ENVIRONMENT; + } + + /** + * @return string Absolute path of the configuration file used for the test + * @since 2.7.9 3.0.4 3.1.0 + */ + protected function GetConfigFileAbsPath(): string + { + return utils::GetConfigFilePath($this->GetTestEnvironment()); + } + + /** + * Prepare the iTop environment for test to run + * + * @return void + * @throws \CoreException + * @throws \DictExceptionUnknownLanguage + * @throws \MySQLException + * @since 2.7.9 3.0.4 3.1.0 + */ + protected function PrepareEnvironment(): void + { + $sEnv = $this->GetTestEnvironment(); + $sConfigFile = $this->GetConfigFileAbsPath(); + + // Start MetaModel for the prepared environment + MetaModel::Startup($sConfigFile, false /* $bModelOnly */, true /* $bAllowCache */, false /* $bTraceSourceFiles */, $sEnv); + } + /** * @return mixed */ diff --git a/tests/php-unit-tests/ItopTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php similarity index 86% rename from tests/php-unit-tests/ItopTestCase.php rename to tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php index c2792bf22..91824ccdc 100644 --- a/tests/php-unit-tests/ItopTestCase.php +++ b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php @@ -1,29 +1,10 @@ + * @package Combodo\iTop\Test\UnitTest + */ +abstract class ItopTestCase extends TestCase +{ public const TEST_LOG_DIR = 'test'; protected function setUp(): void { @@ -51,6 +41,9 @@ class ItopTestCase extends TestCase { // setUp might be called multiple times, so protecting the define() call ! define(ITOP_PHPUNIT_RUNNING_CONSTANT_NAME, true); } + + $this->LoadRequiredItopFiles(); + $this->LoadRequiredTestFiles(); } /** @@ -67,6 +60,28 @@ class ItopTestCase extends TestCase { } } + /** + * Overload this method to require necessary files through {@see \Combodo\iTop\Test\UnitTest\ItopTestCase::RequireOnceItopFile()} + * + * @return void + * @since 2.7.9 3.0.4 3.1.0 + */ + protected function LoadRequiredItopFiles(): void + { + // Empty until we actually need to require some files in the class + } + + /** + * Overload this method to require necessary files through {@see \Combodo\iTop\Test\UnitTest\ItopTestCase::RequireOnceUnitTestFile()} + * + * @return void + * @since 2.7.10 3.0.4 3.1.0 + */ + protected function LoadRequiredTestFiles(): void + { + // Empty until we actually need to require some files in the class + } + /** * Require once an iTop file (core or extension) from its relative path to the iTop root dir. * This ensure to always use the right absolute path, especially in {@see \Combodo\iTop\Test\UnitTest\ItopTestCase::RequireOnceUnitTestFile()} diff --git a/tests/php-unit-tests/src/Hook/TestsRunStartHook.php b/tests/php-unit-tests/src/Hook/TestsRunStartHook.php new file mode 100644 index 000000000..7d2f6c212 --- /dev/null +++ b/tests/php-unit-tests/src/Hook/TestsRunStartHook.php @@ -0,0 +1,63 @@ + + * @package Combodo\iTop\Test\UnitTest\Hook + * @since N°6097 2.7.10 3.0.4 3.1.1 + */ +class TestsRunStartHook implements BeforeFirstTestHook, AfterLastTestHook +{ + /** + * Use the modification time on this file to check whereas it is newer than the requirements in a test case + * + * @return string Abs. path to a file generated when the global tests run starts. + */ + public static function GetRunStartedFileAbsPath(): string + { + // Note: This can't be put in the cache- folder as we have multiple running across the test cases + // We also don't want to put it in the unit tests folder as it is not supposed to be writable + return APPROOT.'data/.php-unit-tests-run-started'; + } + + /** + * @inheritDoc + */ + public function executeBeforeFirstTest(): void + { + // Create / change modification timestamp of file marking the beginning of the tests run + touch(static::GetRunStartedFileAbsPath()); + } + + /** + * @inheritDoc + */ + public function executeAfterLastTest(): void + { + // Cleanup of file marking the beginning of the tests run + if (file_exists(static::GetRunStartedFileAbsPath())) { + unlink(static::GetRunStartedFileAbsPath()); + } + } + + +} \ No newline at end of file diff --git a/tests/php-unit-tests/src/Service/UnitTestRunTimeEnvironment.php b/tests/php-unit-tests/src/Service/UnitTestRunTimeEnvironment.php new file mode 100644 index 000000000..63ded52c7 --- /dev/null +++ b/tests/php-unit-tests/src/Service/UnitTestRunTimeEnvironment.php @@ -0,0 +1,86 @@ + + * @since N°6097 2.7.10 3.0.4 3.1.1 + */ +class UnitTestRunTimeEnvironment extends RunTimeEnvironment +{ + /** + * @inheritDoc + */ + protected function GetMFModulesToCompile($sSourceEnv, $sSourceDir) + { + $aRet = parent::GetMFModulesToCompile($sSourceEnv, $sSourceDir); + + /** @var string[] $aDeltaFiles Referential of loaded deltas. Mostly to avoid duplicates. */ + $aDeltaFiles = []; + foreach (get_declared_classes() as $sClass) { + // Filter on classes derived from this \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCaseItopCustomDatamodelTestCase + if (false === is_a($sClass, ItopCustomDatamodelTestCase::class, true)) { + continue; + } + + $oReflectionClass = new ReflectionClass($sClass); + $oReflectionMethod = $oReflectionClass->getMethod('GetDatamodelDeltaAbsPath'); + + // Filter on classes with an actual XML delta (eg. not \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase and maybe some other deriving from a class with a delta) + if ($oReflectionMethod->isAbstract()) { + continue; + } + + /** @var \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase $oTestClassInstance */ + $oTestClassInstance = new $sClass(); + + // Check test class is for desired environment + if ($oTestClassInstance->GetTestEnvironment() !== $this->sFinalEnv) { + continue; + } + + // Check XML delta actually exists + $sDeltaFile = $oTestClassInstance->GetDatamodelDeltaAbsPath(); + if (false === is_file($sDeltaFile)) { + $this->fail("Could not prepare '$this->sFinalEnv' as the XML delta file '$sDeltaFile' (used in $sClass) does not seem to exist"); + } + + // Avoid duplicates + if (in_array($sDeltaFile, $aDeltaFiles)) { + continue; + } + + // Prepare fake module name for delta + $sDeltaName = preg_replace('/[^\d\w]/', '', $sDeltaFile); + // Note: We can't use \MFDeltaModule as we can't specify the ID which leads to only 1 delta being applied... In the future we might introduce a new MFXXXModule, but in the meantime it feels alright (GLA / RQU) + $oDelta = new MFCoreModule($sDeltaName, $sDeltaName, $sDeltaFile); + + IssueLog::Debug('XML delta found for unit tests', static::class, [ + 'Unit test class' => $sClass, + 'Delta file path' => $sDeltaFile, + ]); + + $aDeltaFiles[] = $sDeltaFile; + $aRet[$sDeltaName] = $oDelta; + } + + return $aRet; + } + +} \ No newline at end of file diff --git a/tests/php-unit-tests/unittestautoload.php b/tests/php-unit-tests/unittestautoload.php index 1e96a56bf..8b720f250 100644 --- a/tests/php-unit-tests/unittestautoload.php +++ b/tests/php-unit-tests/unittestautoload.php @@ -1,9 +1,7 @@