RequireOnceItopFile('setup/moduleinstaller.class.inc.php'); } public function tearDown(): void { foreach ([static::$sWorkTable, static::$sWorkTable2, static::$sWorkTable3] as $sTable) { if (CMDBSource::IsTable($sTable)) { CMDBSource::DropTable($sTable); } } parent::tearDown(); } /** * @param string $sTable * @param string $sAttCode * * @return array * @throws \CoreException */ protected function GetInfoFromTable(string $sTable, string $sAttCode): array { $sOrigTable = MetaModel::DBGetTable($sTable); $oOrigAttDef = MetaModel::GetAttributeDef($sTable, $sAttCode); $sOrigColName = array_key_first($oOrigAttDef->GetSQLColumns()); return [$sOrigTable, $sOrigColName]; } /** * @param string $sDstTable * @param array $aOrigTables * @param string $sDstExistingColName * * @return void * @throws \MySQLException * @throws \MySQLHasGoneAwayException */ protected function CreateDestinationTable(string $sDstTable, array $aOrigTables, string $sDstExistingColName): void { // Create a table with the same structure as the original table(s) // - Create a SQL query to get all the ids from the original tables if (is_array($aOrigTables)) { $sOrigDataQuery = implode(" UNION ", array_map(fn ($sTable) => "SELECT id FROM `{$sTable}`", $aOrigTables)); } CMDBSource::Query( <<GetInfoFromTable($sOrigClass, $sOrigAttCode); // Info for the destination table $sDstTable = static::$sWorkTable; $sDstNonExistingColName = "non_existing_column"; $sDstExistingColName = "existing_column"; $this->CreateDestinationTable($sDstTable, [$sOrigTable], $sDstExistingColName); // Save value from original table as a reference $oPerson = MetaModel::GetObject($sOrigClass, 1); $sOrigValue = $oPerson->Get($sOrigAttCode); // Try to move data $sDstColName = $bDstColAlreadyExists ? $sDstExistingColName : $sDstNonExistingColName; ModuleInstallerAPI::MoveColumnInDB($sOrigTable, $sOrigColName, $sDstTable, $sDstColName, $bIgnoreExistingDstColumn); // Check if data was actually moved // - Either way, the column should exist $sDstValue = CMDBSource::QueryToScalar( <<assertEquals($sOrigValue, $sDstValue, "Data was not moved as expected"); } else { $this->assertEquals(null, $sDstValue, "Data should NOT have moved"); } } public function MoveColumnInDB_IgnoreExistingDstColumnParamProvider(): array { return [ "Nominal use case, move data to a non-existing column" => [ "Dest. col. already exists?" => false, "bIgnoreExistingDstColumn param" => false, "Should work" => true, ], "Move data to existing table fails if not explicitly wanted" => [ "Dest. col. already exists?" => true, "bIgnoreExistingDstColumn param" => false, "Should work" => false, ], "Move data to existing table on purpose" => [ "Dest. col. already exists?" => true, "bIgnoreExistingDstColumn param" => true, "Should work" => true, ], ]; } /** * Test that if we move two columns into the same one using $bIgnoreExistingDstColumn, we don't lose data from one of the columns * * @return void * @throws \CoreException * @throws \MySQLException * @throws \MySQLHasGoneAwayException * @throws \MySQLQueryHasNoResultException */ public function testMoveColumnInDB_MoveMultipleTable(): void { // Create 2 objects, so we know the ids for one of each class $oPerson = $this->createObject('Person', ['first_name' => 'John', 'name' => 'Doe', 'org_id' => 1]); $oTeam = $this->createObject('Team', ['name' => 'La tcheam', 'org_id' => 1]); // Info from the original tables (we don't need real data, just the ids) $sOrigClass = "Person"; [$sOrigTable, $sOrigColName] = $this->GetInfoFromTable($sOrigClass, "first_name"); $sOrigClass2 = "Team"; [$sOrigTable2, $sOrigColName2] = $this->GetInfoFromTable($sOrigClass2, "friendlyname"); // Info for the destination table $sDstTable = static::$sWorkTable3; $sDstColName = "existing_column"; // Insert our ids into similar work tables // Then insert data into their respective columns to be moved $sOrigWorkTable = static::$sWorkTable; $this->CreateDestinationTable($sOrigWorkTable, [$sOrigTable], $sDstColName); CMDBSource::Query( <<CreateDestinationTable($sOrigWorkTable2, [$sOrigTable2], $sDstColName); CMDBSource::Query( <<CreateDestinationTable($sDstTable, [$sOrigTable, $sOrigTable2], $sDstColName); // Try to move data from both tables into the same column ModuleInstallerAPI::MoveColumnInDB($sOrigWorkTable, $sDstColName, $sDstTable, $sDstColName, true); ModuleInstallerAPI::MoveColumnInDB($sOrigWorkTable2, $sDstColName, $sDstTable, $sDstColName, true); // Check if data was actually moved by getting the value from the destination table for the ids we stored earlier $iPersonId = $oPerson->GetKey(); $sFromTable1Data = CMDBSource::QueryToScalar( <<GetKey(); $sFromTable2Data = CMDBSource::QueryToScalar( <<assertEquals('from table 1', $sFromTable1Data, "Data was not moved as expected"); $this->assertEquals('from table 2', $sFromTable2Data, "Data was not moved as expected"); } /** * Test that moving columns from/to the same table works * * @covers \ModuleInstallerAPI::MoveColumnInDB * * @return void * @throws \ArchivedObjectException * @throws \CoreException * @throws \MySQLException * @throws \MySQLHasGoneAwayException * @throws \MySQLQueryHasNoResultException */ public function testMoveColumnInDB_SameTable(): void { // Info from the original table $sOrigClass = "Person"; $sOrigAttCode = "first_name"; [$sOrigTable, $sOrigColName] = $this->GetInfoFromTable($sOrigClass, $sOrigAttCode); // Info for the destination column $sDstNonExistingColName = "non_existing_column"; // Save value from original table as a reference $oPerson = MetaModel::GetObject($sOrigClass, 1); $sOrigValue = $oPerson->Get($sOrigAttCode); // Try to move data ModuleInstallerAPI::MoveColumnInDB($sOrigTable, $sOrigColName, $sOrigTable, $sDstNonExistingColName); // Check if data was actually moved // - Either way, the column should exist $sDstValue = CMDBSource::QueryToScalar( <<assertEquals($sOrigValue, $sDstValue, "Data was not moved as expected"); } /** * @covers \ModuleInstallerAPI::LoadLocalizedData */ public function testLoadLocalizedData_LoadsOnFirstInstall(): 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); // 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); } /** * @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 { 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'], ]; } public function LoadLocalizedData_InvalidParametersProvider(): array { $sTmpDir = static::CreateTmpdir(); $this->aFileToClean[] = $sTmpDir; return [ 'Invalid previous version format' => [ 'previous' => 'v3.2', 'current' => '3.2.0', 'first' => '3.0.0', 'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml', 'message' => 'sPreviousVersion', ], 'Invalid current version format' => [ 'previous' => '', 'current' => '3', 'first' => '3.0.0', 'pattern' => $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.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', '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} */ private function GivenLocalizedDataTestContext(string $sOrgNamePrefix, string $sLanguage): array { $oConfig = MetaModel::GetConfig(); $oConfig->SetDefaultLanguage($sLanguage); $this->assertNotNull($oConfig); $sOrgName = $sOrgNamePrefix.uniqid(); $sTmpDir = static::CreateTmpdir(); $this->aFileToClean[] = $sTmpDir; $sPattern = $sTmpDir.DIRECTORY_SEPARATOR.'data.{{language_code}}.xml'; return [$oConfig, $sOrgName, $sTmpDir, $sPattern]; } private function GivenLocalizedDataFile(string $sDir, string $sLang, string $sOrgName): string { $sFilePath = $sDir.DIRECTORY_SEPARATOR.'data.'.$sLang.'.xml'; file_put_contents($sFilePath, $this->BuildOrganizationXml($sOrgName, $sLang)); return $sFilePath; } private function BuildOrganizationXml(string $sOrgName, string $sLang): string { $iId = random_int(100000, 999999); $sOrgNameXml = htmlspecialchars($sOrgName, ENT_XML1); return << {$sOrgNameXml} {$sLang} active XML; } private function AssertOrganizationCountByName(string $sOrgName, string $sLanguage, int $iExpectedCount): void { $oSet = new \DBObjectSet( \DBSearch::FromOQL("SELECT Organization WHERE name = :org_name AND code = :language"), [], ['org_name' => $sOrgName, 'language' => $sLanguage] ); $iCount = $oSet->Count(); $this->assertEquals($iExpectedCount, $iCount, "Found $iCount changes for objects with name '{$sOrgName}' and language '{$sLanguage}', expected {$iExpectedCount}"); } }