Compare commits

..

3 Commits

Author SHA1 Message Date
v-dumas
b68df9ec07 N°9553 - fix typo 2026-06-01 12:47:24 +02:00
v-dumas
a8b8e2d81d N°9553 - take into account feedbacks from review 2026-06-01 12:39:26 +02:00
v-dumas
9443d87172 N°9553 - LoadLocalizedData: review feedbacks + improved tests 2026-06-01 12:34:18 +02:00
7 changed files with 129 additions and 172 deletions

View File

@@ -17,7 +17,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with iTop. If not, see <http://www.gnu.org/licenses/>
use Combodo\iTop\Application\Helper\Session;
use Combodo\iTop\Service\Startup\StartupService;
require_once(APPROOT.'core/cmdbobject.class.inc.php');
require_once(APPROOT.'application/utils.inc.php');
@@ -70,7 +69,26 @@ $oKPI->ComputeAndReport("Session Start");
$sSwitchEnv = utils::ReadParam('switch_env', null);
$bAllowCache = true;
$sEnv = StartupService::SetItopEnvironment($sSwitchEnv, $bAllowCache);
if (($sSwitchEnv != null) && file_exists(APPCONF.$sSwitchEnv.'/'.ITOP_CONFIG_FILE) && (Session::Get('itop_env') !== $sSwitchEnv)) {
Session::Set('itop_env', $sSwitchEnv);
$sEnv = $sSwitchEnv;
$bAllowCache = false;
// Reset the opcache since otherwise the PHP "model" files may still be cached !!
if (function_exists('opcache_reset')) {
// Zend opcode cache
opcache_reset();
}
if (function_exists('apc_clear_cache')) {
// APC(u) cache
apc_clear_cache();
}
// TODO: reset the credentials as well ??
} elseif (Session::IsSet('itop_env')) {
$sEnv = Session::Get('itop_env');
} else {
$sEnv = ITOP_DEFAULT_ENV;
Session::Set('itop_env', ITOP_DEFAULT_ENV);
}
$sConfigFile = APPCONF.$sEnv.'/'.ITOP_CONFIG_FILE;
try {
MetaModel::Startup($sConfigFile, false /* $bModelOnly */, $bAllowCache, false /* $bTraceSourceFiles */, $sEnv);

View File

@@ -638,7 +638,6 @@ return array(
'Combodo\\iTop\\Service\\Router\\Exception\\RouteNotFoundException' => $baseDir . '/sources/Service/Router/Exception/RouteNotFoundException.php',
'Combodo\\iTop\\Service\\Router\\Exception\\RouterException' => $baseDir . '/sources/Service/Router/Exception/RouterException.php',
'Combodo\\iTop\\Service\\Router\\Router' => $baseDir . '/sources/Service/Router/Router.php',
'Combodo\\iTop\\Service\\Startup\\StartupService' => $baseDir . '/sources/Service/Startup/StartupService.php',
'Combodo\\iTop\\Service\\SummaryCard\\SummaryCardService' => $baseDir . '/sources/Service/SummaryCard/SummaryCardService.php',
'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectConfig' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectConfig.php',
'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectGC' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectGC.php',

View File

@@ -1039,7 +1039,6 @@ class ComposerStaticInitfc0e9e9dea11dcbb6272414776c30685
'Combodo\\iTop\\Service\\Router\\Exception\\RouteNotFoundException' => __DIR__ . '/../..' . '/sources/Service/Router/Exception/RouteNotFoundException.php',
'Combodo\\iTop\\Service\\Router\\Exception\\RouterException' => __DIR__ . '/../..' . '/sources/Service/Router/Exception/RouterException.php',
'Combodo\\iTop\\Service\\Router\\Router' => __DIR__ . '/../..' . '/sources/Service/Router/Router.php',
'Combodo\\iTop\\Service\\Startup\\StartupService' => __DIR__ . '/../..' . '/sources/Service/Startup/StartupService.php',
'Combodo\\iTop\\Service\\SummaryCard\\SummaryCardService' => __DIR__ . '/../..' . '/sources/Service/SummaryCard/SummaryCardService.php',
'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectConfig' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectConfig.php',
'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectGC' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectGC.php',

View File

@@ -311,7 +311,7 @@ abstract class ModuleInstallerAPI
/**
* @param \Config $oConfiguration
* @param string $sPreviousVersion The previous version of the module (empty string will force the loading)
* @param string $sPreviousVersion The previous version of the module (empty string in case of first install)
* @param string $sCurrentVersion The current version of the module
* @param string $sFirstLoadingVersion The first module version for which the data loading should be performed (e.g. '3.0.0')
* @param string $sFilePattern The pattern of the file to load, with {{language_code}} as placeholder for the language code (e.g. 'data.sample.{{language_code}}.xml')
@@ -321,57 +321,41 @@ abstract class ModuleInstallerAPI
* @throws \CoreException
* @throws \CoreUnexpectedValue
*/
public static function LoadLocalizedData(Config $oConfiguration, string $sPreviousVersion, string $sCurrentVersion, string $sFirstLoadingVersion, string $sFilePattern): void
public static function LoadLocalizedData(Config $oConfiguration, ?string $sPreviousVersion, ?string $sCurrentVersion, string $sFirstLoadingVersion, string $sFilePattern): void
{
self::AssertLoadLocalizedDataParametersAreValid($sPreviousVersion, $sCurrentVersion, $sFirstLoadingVersion, $sFilePattern);
// It's not very clear if it makes sense to test a particular version,
// as the loading mechanism checks object existence using reconc_keys
// and do not recreate them, nor update existing.
// Without test, new entries added to the data files, would be automatically loaded
// The loading is done only if
// - it's a first install of the module
// - or it's an upgrade of that module (PreviousVersion is less than the CurrentVersion), which means that we are really upgrading (and not reinstalling the same version or downgrading), and
// - either the FirstLoadingVersion is between the PreviousVersion and the CurrentVersion
// - or the FirstLoadingVersion is empty, forcing the loading on all upgrades,
if (($sPreviousVersion === '') ||
(version_compare($sPreviousVersion, $sCurrentVersion, '<')
&& version_compare($sPreviousVersion, $sFirstLoadingVersion, '<'))) {
// Note: There is an issue when upgrading, default language cannot be retrieved from the passed configuration, we have to read it from the disk
if (utils::IsNullOrEmptyString($sPreviousVersion)) {
// Fresh install
$sDefaultLanguage = $oConfiguration->GetDefaultLanguage();
} else {
// Upgrade
$sDefaultLanguage = utils::GetConfig(true)->GetDefaultLanguage();
}
(version_compare($sPreviousVersion, $sCurrentVersion, '<') &&
(($sFirstLoadingVersion === '') || version_compare($sPreviousVersion, $sFirstLoadingVersion, '<')))) {
$sDefaultLanguage = $oConfiguration->GetDefaultLanguage();
$sFileName = self::GetLocalizedFileName($sDefaultLanguage, $sFilePattern);
if ($sFileName !== '') {
self::XMLFileLoad($sFileName);
}
self::XMLFileLoad($sFileName);
}
}
/**
* @throws \CoreUnexpectedValue
*/
private static function AssertLoadLocalizedDataParametersAreValid(string $sPreviousVersion, string $sCurrentVersion, string $sFirstLoadingVersion, string $sFilePattern): void
private static function AssertLoadLocalizedDataParametersAreValid(?string $sPreviousVersion, ?string $sCurrentVersion, string $sFirstLoadingVersion, string $sFilePattern): void
{
if (($sPreviousVersion !== '') && !self::IsValidLocalizedDataVersion($sPreviousVersion)) {
throw new CoreUnexpectedValue("LoadLocalizedData expects sPreviousVersion to be empty or match x.y[.z][-name], got '{$sPreviousVersion}'");
}
if (!self::IsValidLocalizedDataVersion($sCurrentVersion)) {
throw new CoreUnexpectedValue("LoadLocalizedData expects sCurrentVersion to match x.y[.z][-name], got '{$sCurrentVersion}'");
}
if (!self::IsValidLocalizedDataVersion($sFirstLoadingVersion)) {
if (($sFirstLoadingVersion !== '') && !self::IsValidLocalizedDataVersion($sFirstLoadingVersion)) {
throw new CoreUnexpectedValue("LoadLocalizedData expects sFirstLoadingVersion to match x.y[.z][-name], got '{$sFirstLoadingVersion}'");
}
if (utils::IsNullOrEmptyString($sFilePattern)) {
throw new CoreUnexpectedValue('LoadLocalizedData expects sFilePattern to be a non-empty string');
}
if (substr_count($sFilePattern, '{{language_code}}') !== 1) {
throw new CoreUnexpectedValue("LoadLocalizedData expects sFilePattern to contain the exact placeholder '{{language_code}}' exactly once");
throw new CoreUnexpectedValue("LoadLocalizedData expects $sFilePattern to contain the exact placeholder '{{language_code}}' exactly once");
}
}
@@ -382,7 +366,6 @@ abstract class ModuleInstallerAPI
/**
* @param array|string $sFileName
* @param \XMLDataLoader $oDataLoader
*
* @return void
* @throws \Exception
@@ -406,14 +389,13 @@ abstract class ModuleInstallerAPI
* @param string $sFilePattern The full path+name of the file to localize, with {{language_code}} as placeholder for the language code (e.g. 'data.sample.{{language_code}}.xml')
*
* @return string The localized file name if found, or an empty string if not found
* @throws \ConfigException
* @throws \CoreException
*/
public static function GetLocalizedFileName($sLanguage, string $sFilePattern): string
{
$sLang = str_replace(' ', '_', strtolower($sLanguage));
$sFileName = str_replace('{{language_code}}', $sLang, $sFilePattern);
if (!file_exists($sFileName)) {
SetupLog::Debug("No data file found matching the pattern $sFilePattern and language_code $sLang. Trying with 'en_us' as fallback.");
$sLang = 'en_us';
$sFileName = str_replace('{{language_code}}', $sLang, $sFilePattern);
}

View File

@@ -1,58 +0,0 @@
<?php
namespace Combodo\iTop\Service\Startup;
use Combodo\iTop\Application\Helper\Session;
use CoreException;
use IssueLog;
class StartupService
{
/**
* @param string|null $sSwitchEnv
* @param bool $bAllowCache
*
* @return string
* @throws CoreException
*/
public static function SetItopEnvironment(?string $sSwitchEnv, bool &$bAllowCache): string
{
if (static::IsBuildEnvironment($sSwitchEnv)) {
$oException = new CoreException("Switching to environment '$sSwitchEnv' is not allowed since it is a build environment");
IssueLog::Exception("Trying to switch to environment '$sSwitchEnv' is not allowed since it is a build environment", $oException);
throw $oException;
}
if (
($sSwitchEnv != null)
&& file_exists(APPCONF.$sSwitchEnv.'/'.ITOP_CONFIG_FILE)
&& (Session::Get('itop_env') !== $sSwitchEnv)
) {
Session::Set('itop_env', $sSwitchEnv);
$sEnv = $sSwitchEnv;
$bAllowCache = false;
// Reset the opcache since otherwise the PHP "model" files may still be cached !!
if (function_exists('opcache_reset')) {
// Zend opcode cache
opcache_reset();
}
if (function_exists('apc_clear_cache')) {
// APC(u) cache
apc_clear_cache();
}
// TODO: reset the credentials as well ??
} elseif (Session::IsSet('itop_env')) {
$sEnv = Session::Get('itop_env');
} else {
$sEnv = ITOP_DEFAULT_ENV;
Session::Set('itop_env', ITOP_DEFAULT_ENV);
}
return $sEnv;
}
public static function IsBuildEnvironment(?string $sEnv): bool
{
return $sEnv != null && str_ends_with($sEnv, '-build');
}
}

View File

@@ -286,47 +286,87 @@ SQL
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
* @dataProvider LoadLocalizedData_RequiredLanguageProvider
*/
public function testLoadLocalizedData_LoadsOnFirstInstall(): void
public function testLoadLocalizedData_LoadRequiredLanguageOnFirstInstall(string $sRequiredLanguage, array $aAvailableLanguages, array $aExpectedCountByLanguage): void
{
// Given
[$oConfig, $sOrgName, $sTmpDir, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_FirstInstall_', 'fr_fr');
$this->GivenLocalizedDataFile($sTmpDir, "en_us", $sOrgName);
$this->GivenLocalizedDataFile($sTmpDir, "fr_fr", $sOrgName);
[$oConfig, $sOrgName, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_RequiredLanguage_', $sRequiredLanguage, $aAvailableLanguages);
// When no previous version, and current version higher than the first loading version
ModuleInstallerAPI::LoadLocalizedData($oConfig, '', '3.3.0', '3.0.0', $sPattern);
// Then data loaded
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 0);
$this->AssertOrganizationCountByName($sOrgName, 'fr_fr', 1);
foreach ($aExpectedCountByLanguage as $sLanguage => $iExpectedCount) {
$this->AssertOrganizationCountByName($sOrgName, $sLanguage, $iExpectedCount);
}
}
public function LoadLocalizedData_RequiredLanguageProvider(): array
{
return [
'Required fr_fr and file exists' => [
'required language' => 'fr_fr',
'available languages' => ['en_us', 'fr_fr'],
'expected counts' => ['en_us' => 0, 'fr_fr' => 1],
],
'Required en_us and file exists' => [
'required language' => 'en_us',
'available languages' => ['en_us', 'fr_fr'],
'expected counts' => ['en_us' => 1, 'fr_fr' => 0],
],
'Required fr_fr but fallback to en_us' => [
'required language' => 'fr_fr',
'available languages' => ['en_us'],
'expected counts' => ['en_us' => 1, 'fr_fr' => 0],
],
'Required de_de and file exists' => [
'required language' => 'de_de',
'available languages' => ['en_us', 'fr_fr', 'de_de'],
'expected counts' => ['en_us' => 0, 'fr_fr' => 0, 'de_de' => 1],
],
'Required de_de but fallback to en_us' => [
'required language' => 'de_de',
'available languages' => ['en_us', 'fr_fr'],
'expected counts' => ['en_us' => 1, 'fr_fr' => 0, 'de_de' => 0],
],
];
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
* @dataProvider LoadLocalizedData_VersionConditionNotMetProvider
*/
public function testLoadLocalizedData_DoesNotLoadWhenVersionConditionIsNotMet(): void
public function testLoadLocalizedData_DoesNotLoadWhenVersionConditionIsNotMet(string $sPreviousVersion, string $sCurrentVersion, string $sFirstLoadingVersion): void
{
// Given
[$oConfig, $sOrgName, $sTmpDir, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_NoLoad_', 'en_us');
$this->GivenLocalizedDataFile($sTmpDir, "en_us", $sOrgName);
// When a previous version that is lower than the first loading version, but higher or equal to the current version
ModuleInstallerAPI::LoadLocalizedData($oConfig, '3.0.0', '3.1.0', '3.0.0', $sPattern);
[$oConfig, $sOrgName, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_NoLoad_', 'en_us', ['en_us']);
// When version gate conditions are not met
ModuleInstallerAPI::LoadLocalizedData($oConfig, $sPreviousVersion, $sCurrentVersion, $sFirstLoadingVersion, $sPattern);
// Then no data loaded
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 0);
}
public function LoadLocalizedData_VersionConditionNotMetProvider(): array
{
return [
'Equal versions (reinstall)' => ['3.1.0', '3.1.0', '3.0.0'],
'Downgrade attempt' => ['3.2.0', '3.1.0', '3.0.0'],
'Upgrade but first loading version already passed' => ['3.1.0', '3.2.0', '3.0.0'],
'Upgrade with boundary equality on first loading version' => ['3.0.0', '3.1.0', '3.0.0'],
];
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
*/
public function testLoadLocalizedData_FallbacksToEnUsWhenLanguageFileIsMissing(): void
public function testLoadLocalizedData_AlwaysLoadWhenFistLoadingVersionEmpty(): void
{
[$oConfig, $sOrgName, $sTmpDir, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_Fallback_', 'fr_fr');
// Intentionally create ONLY en_us file
$this->GivenLocalizedDataFile($sTmpDir, 'en_us', $sOrgName);
// When loading localized data in fr_fr, but only en_us file exists
ModuleInstallerAPI::LoadLocalizedData($oConfig, '', '3.3.0', '3.0.0', $sPattern);
// Given
[$oConfig, $sOrgName, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_NoLoad_', 'en_us', ['en_us']);
$this->AssertOrganizationCountByName($sOrgName, 'fr_fr', 0);
// When a previous version that is lower than the first loading version, but higher or equal to the current version
ModuleInstallerAPI::LoadLocalizedData($oConfig, '3.0.0', '3.1.0', '', $sPattern);
// Then no data loaded
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 1);
}
@@ -336,8 +376,7 @@ SQL
*/
public function testLoadLocalizedData_AcceptsSupportedVersionFormats(string $sCurrentVersion, string $sFirstLoadingVersion): void
{
[$oConfig, $sOrgName, $sTmpDir, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_ValidVersion_', 'en_us');
$this->GivenLocalizedDataFile($sTmpDir, 'en_us', $sOrgName);
[$oConfig, $sOrgName, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_ValidVersion_', 'en_us', ['en_us']);
ModuleInstallerAPI::LoadLocalizedData($oConfig, '', $sCurrentVersion, $sFirstLoadingVersion, $sPattern);
@@ -353,6 +392,34 @@ SQL
'Current version x.y.z-1' => ['1.2.4-1', '1.0.3-2'],
];
}
// Test when a file is loaded twice because of the version conditions, it doesn't create duplicates (idempotent loading)
public function testLoadLocalizedData_IdempotentLoading(): void
{
// Given
[$oConfig, $sOrgName, $sPattern] = $this->GivenLocalizedDataTestContext('XML_Load_Idempotent_', 'en_us', ['en_us']);
// When LoadLocalizedData is called twice with conditions that would load the file both times
ModuleInstallerAPI::LoadLocalizedData($oConfig, '', '3.1.0', '3.0.0', $sPattern);
ModuleInstallerAPI::LoadLocalizedData($oConfig, '3.1.0', '3.2.0', '', $sPattern);
// Then no duplicate data loaded
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 1);
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
* @dataProvider LoadLocalizedData_InvalidParametersProvider
*/
public function testLoadLocalizedData_ThrowsOnInvalidParameters(string $sPreviousVersion, string $sCurrentVersion, string $sFirstLoadingVersion, string $sPattern, string $sExpectedMessage): void
{
$oConfig = MetaModel::GetConfig();
$this->assertNotNull($oConfig);
$this->expectException(\CoreUnexpectedValue::class);
$this->expectExceptionMessage($sExpectedMessage);
ModuleInstallerAPI::LoadLocalizedData($oConfig, $sPreviousVersion, $sCurrentVersion, $sFirstLoadingVersion, $sPattern);
}
public function LoadLocalizedData_InvalidParametersProvider(): array
{
@@ -388,13 +455,6 @@ SQL
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.{{LANGUAGE_CODE}}.xml',
'message' => "{{language_code}}",
],
'Parent directory does not exist' => [
'previous' => '',
'current' => '3.2.0',
'first' => '3.0.0',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'missing'.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml',
'message' => 'parent directory',
],
];
}
@@ -403,7 +463,7 @@ SQL
*
* @return array{0: Config, 1: string, 2: string, 3: string, 4: string}
*/
private function GivenLocalizedDataTestContext(string $sOrgNamePrefix, string $sLanguage): array
private function GivenLocalizedDataTestContext(string $sOrgNamePrefix, string $sLanguage, array $aAvailableLanguages = []): array
{
$oConfig = MetaModel::GetConfig();
$oConfig->SetDefaultLanguage($sLanguage);
@@ -415,7 +475,11 @@ SQL
$this->aFileToClean[] = $sTmpDir;
$sPattern = $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml';
return [$oConfig, $sOrgName, $sTmpDir, $sPattern];
foreach ($aAvailableLanguages as $sAvailableLanguage) {
$this->GivenLocalizedDataFile($sTmpDir, $sAvailableLanguage, $sOrgName);
}
return [$oConfig, $sOrgName, $sPattern];
}
private function GivenLocalizedDataFile(string $sDir, string $sLang, string $sOrgName): string

View File

@@ -1,47 +0,0 @@
<?php
namespace Service\Startup;
use Combodo\iTop\Service\Startup\StartupService;
use Combodo\iTop\Test\UnitTest\ItopTestCase;
use CoreException;
class StartupServiceTest extends ItopTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->RequireOnceItopFile('application/utils.inc.php');
}
public function testSetItopEnvironmentUsesDefaultWhenEnvironmentIsNull(): void
{
$bAllowCache = true;
$sEnv = StartupService::SetItopEnvironment(null, $bAllowCache);
$this->assertEquals(ITOP_DEFAULT_ENV, $sEnv);
$this->assertTrue($bAllowCache);
}
public function testSetItopEnvironmentWithValidEnvironment(): void
{
$bAllowCache = true;
$sEnv = StartupService::SetItopEnvironment('test', $bAllowCache);
$this->assertEquals('test', $sEnv);
$this->assertFalse($bAllowCache);
}
public function testSetItopEnvironmentThrowsForBuildEnvironment()
{
$bAllowCache = true;
$this->expectException(CoreException::class);
$this->expectExceptionMessage("Switching to environment 'test-build' is not allowed since it is a build environment");
StartupService::SetItopEnvironment('test-build', $bAllowCache);
}
public function testIsBuildEnvironment()
{
$this->assertTrue(StartupService::IsBuildEnvironment('test-build'));
$this->assertFalse(StartupService::IsBuildEnvironment('test'));
$this->assertFalse(StartupService::IsBuildEnvironment(null));
}
}