N°9553 - Allow loading of localized sample and structural data based on iTop default language

* Add English, French and German structural and sample data in iTop Community

* Add helper methods to allow :
  * Checking of supported module version format
  * Finding a localized file name based on a provided en_us file name and a language
  * Loading a file if the module upgrade crossed a given version
This commit is contained in:
Vincent Dumas
2026-07-10 14:21:56 +02:00
committed by GitHub
parent b14045af29
commit eacb7f3401
29 changed files with 1966 additions and 221 deletions

View File

@@ -285,75 +285,135 @@ SQL
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
* @covers \ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion
* @dataProvider LoadLocalizedData_RequiredLanguageProvider
*/
public function testLoadLocalizedData_LoadsOnFirstInstall(): void
public function testLoadLocalizedData_LoadRequiredLanguageOnFirstInstall(string $sRequiredLanguage, array $aExpectedOrganizationNames): 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, $sPattern] = $this->GivenLocalizedDataTestContext($sRequiredLanguage);
// 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);
ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion($oConfig, '', '3.3.0', '3.0.0', $sPattern);
// Then the expected localized organizations are loaded
$this->AssertOrganizationNamesExist($aExpectedOrganizationNames);
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
*/
public function testLoadLocalizedData_DoesNotLoadWhenVersionConditionIsNotMet(): 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);
// Then no data loaded
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 0);
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
*/
public function testLoadLocalizedData_FallbacksToEnUsWhenLanguageFileIsMissing(): 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);
$this->AssertOrganizationCountByName($sOrgName, 'fr_fr', 0);
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 1);
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedData
* @dataProvider LoadLocalizedData_ValidVersionFormatsProvider
*/
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);
ModuleInstallerAPI::LoadLocalizedData($oConfig, '', $sCurrentVersion, $sFirstLoadingVersion, $sPattern);
$this->AssertOrganizationCountByName($sOrgName, 'en_us', 1);
}
public function LoadLocalizedData_ValidVersionFormatsProvider(): array
public function LoadLocalizedData_RequiredLanguageProvider(): array
{
return [
'Current version with suffix' => ['3.2-dev', '3.0.0'],
'Current version x.y.z' => ['1.2.4', '1.0'],
'Current version x.y.z-suffix' => ['2.3.3-beta', '2.0.0'],
'Current version x.y.z-1' => ['1.2.4-1', '1.0.3-2'],
'Required fr_fr and file exists' => [
'required language' => 'fr_fr',
'expected organization names' => ['Client (Test)', 'Département informatique (Test)'],
],
'Required en_us and file exists' => [
'required language' => 'en_us',
'expected organization names' => ['Customer (Test)', 'IT Department (Test)'],
],
'Required de_de but fallback to en_us' => [
'required language' => 'de_de',
'expected organization names' => ['Customer (Test)', 'IT Department (Test)'],
],
];
}
/**
* @covers \ModuleInstallerAPI::IsVersionCrossed
* @dataProvider IsVersionCrossedProvider
*/
public function testIsVersionCrossed_ReturnsExpectedValue(string $sPreviousVersion, string $sCurrentVersion, string $sFirstLoadingVersion, bool $bExpected): void
{
$bIsVersionCrossed = ModuleInstallerAPI::IsVersionCrossed($sPreviousVersion, $sCurrentVersion, $sFirstLoadingVersion);
$this->assertSame($bExpected, $bIsVersionCrossed);
}
public function IsVersionCrossedProvider(): array
{
return [
'First install always crosses' => ['', '3.2.0', '3.0.0', true],
'Upgrade crosses first loading version' => ['3.0.0', '3.2.0', '3.1.0', true],
'Upgrade but first loading version already passed' => ['3.1.0', '3.2.0', '3.0.0', false],
'Reinstall same version does not cross' => ['3.1.0', '3.1.0', '3.0.0', false],
'Downgrade does not cross' => ['3.2.0', '3.1.0', '3.0.0', false],
'First install with suffixed current version' => ['', '3.2-dev', '3.0.0', true],
'Upgrade with suffixed current version crosses' => ['1.0.3-2', '1.2.4-1', '1.1.0', true],
'Upgrade with suffixed current version below threshold does not cross' => ['1.0.0', '1.2.0-beta', '1.2.0', false],
];
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion
*/
public function testLoadLocalizedData_LoadsWhenVersionCrossingIsTrue(): void
{
[$oConfig, $sPattern] = $this->GivenLocalizedDataTestContext('FR FR', 'data.sample.organizations.en_us.xml');
ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion($oConfig, '3.0.0', '3.2.0', '3.1.0', $sPattern);
$this->AssertOrganizationNamesExist(['Client (Test)', 'Département informatique (Test)']);
}
/**
* Test that loading a file twice (because of the version conditions) does not create duplicates (idempotent loading).
* @covers \ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion
*/
public function testLoadLocalizedData_IdempotentLoading(): void
{
// Given
[$oConfig, $sPattern] = $this->GivenLocalizedDataTestContext('en_us', 'data.sample.organizations.en_us.xml');
// When LoadLocalizedData is called twice with conditions that would load the file both times
ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion($oConfig, '', '3.0.1', '3.0.0', $sPattern);
ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion($oConfig, '3.0.1', '3.1.0', '3.0.2', $sPattern);
// Then no duplicated data load side effect and values remain stable
foreach (['Customer (Test)', 'IT Department (Test)'] as $sExpectedName) {
$oSet = new \DBObjectSet(\DBSearch::FromOQL("SELECT Organization WHERE name = :org_name"), [], ['org_name' => $sExpectedName]);
$this->assertSame(1, $oSet->Count(), "Expected exactly one Organization named '{$sExpectedName}' after loading twice (idempotency)");
}
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion
*/
public function testLoadLocalizedData_LoadsRequestedNonEnUsFileWhenItExists(): void
{
[$oConfig, $sPattern] = $this->GivenLocalizedDataTestContext('en_us', 'data.sample.organizations.fr_fr.xml');
ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion($oConfig, '', '3.3.0', '3.0.0', $sPattern);
$this->AssertOrganizationNamesExist(['Client (Test)', 'Département informatique (Test)']);
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion
*/
public function testLoadLocalizedData_ThrowsWhenRequestedEnUsFileDoesNotExist(): void
{
[$oConfig, $sPattern] = $this->GivenLocalizedDataTestContext('en_us', 'data.sample.missing-file.en_us.xml', false);
$this->expectException(\Exception::class);
$this->expectExceptionMessage("File $sPattern not found");
ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion($oConfig, '', '3.3.0', '3.0.0', $sPattern);
}
/**
* @covers \ModuleInstallerAPI::LoadLocalizedDataOnCrossingVersion
* @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::LoadLocalizedDataOnCrossingVersion($oConfig, $sPreviousVersion, $sCurrentVersion, $sFirstLoadingVersion, $sPattern);
}
public function LoadLocalizedData_InvalidParametersProvider(): array
{
$sTmpDir = static::CreateTmpdir();
@@ -364,93 +424,59 @@ SQL
'previous' => 'v3.2',
'current' => '3.2.0',
'first' => '3.0.0',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.en_us.xml',
'message' => 'sPreviousVersion',
],
'Invalid current version format' => [
'previous' => '',
'current' => '3',
'first' => '3.0.0',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.en_us.xml',
'message' => 'sCurrentVersion',
],
'Invalid first loading version format' => [
'previous' => '',
'current' => '3.2.0',
'first' => '3.0.0-beta.1',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml',
'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.en_us.xml',
'message' => 'sFirstLoadingVersion',
],
'Missing strict placeholder' => [
'previous' => '',
'current' => '3.2.0',
'first' => '3.0.0',
'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',
],
];
}
/**
* Prepare common context for LoadLocalizedData tests.
*
* @return array{0: Config, 1: string, 2: string, 3: string, 4: string}
* @return array{0: Config, 1: string}
*/
private function GivenLocalizedDataTestContext(string $sOrgNamePrefix, string $sLanguage): array
private function GivenLocalizedDataTestContext(string $sLanguage, string $sDataFileName = 'data.sample.organizations.en_us.xml', bool $bAssertFileExists = true): array
{
$oConfig = MetaModel::GetConfig();
$oConfig->SetDefaultLanguage($sLanguage);
$this->assertNotNull($oConfig);
$sOrgName = $sOrgNamePrefix.uniqid();
$sPattern = __DIR__.DIRECTORY_SEPARATOR.'resources2'.DIRECTORY_SEPARATOR.'data'.DIRECTORY_SEPARATOR.'load-localized-data'.DIRECTORY_SEPARATOR.$sDataFileName;
if ($bAssertFileExists) {
$this->assertFileExists($sPattern);
}
$sTmpDir = static::CreateTmpdir();
$this->aFileToClean[] = $sTmpDir;
$sPattern = $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml';
return [$oConfig, $sOrgName, $sTmpDir, $sPattern];
return [$oConfig, $sPattern];
}
private function GivenLocalizedDataFile(string $sDir, string $sLang, string $sOrgName): string
private function AssertOrganizationNamesExist(array $aExpectedOrganizationNames): void
{
$sFilePath = $sDir.DIRECTORY_SEPARATOR.'data.'.$sLang.'.xml';
file_put_contents($sFilePath, $this->BuildOrganizationXml($sOrgName, $sLang));
return $sFilePath;
foreach ($aExpectedOrganizationNames as $sExpectedName) {
$this->AssertOrganizationNameExists($sExpectedName);
}
}
private function BuildOrganizationXml(string $sOrgName, string $sLang): string
{
$iId = random_int(100000, 999999);
$sOrgNameXml = htmlspecialchars($sOrgName, ENT_XML1);
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<Set>
<Organization alias="Organization" id="{$iId}">
<name>{$sOrgNameXml}</name>
<code>{$sLang}</code>
<status>active</status>
</Organization>
</Set>
XML;
}
private function AssertOrganizationCountByName(string $sOrgName, string $sLanguage, int $iExpectedCount): void
private function AssertOrganizationNameExists(string $sExpectedName): void
{
$oSet = new \DBObjectSet(
\DBSearch::FromOQL("SELECT Organization WHERE name = :org_name AND code = :language"),
\DBSearch::FromOQL("SELECT Organization WHERE name = :org_name"),
[],
['org_name' => $sOrgName, 'language' => $sLanguage]
['org_name' => $sExpectedName]
);
$iCount = $oSet->Count();
$this->assertEquals($iExpectedCount, $iCount, "Found $iCount changes for objects with name '{$sOrgName}' and language '{$sLanguage}', expected {$iExpectedCount}");
$this->assertEquals(1, $oSet->Count(), "Expected exactly one Organization named '{$sExpectedName}'");
}
}