mirror of
https://github.com/Combodo/iTop.git
synced 2026-07-02 04:36:37 +02:00
Compare commits
5 Commits
issue/9746
...
feature/96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd30fc55a | ||
|
|
7a70e2460a | ||
|
|
e27dcbfb2d | ||
|
|
825c9f3a0e | ||
|
|
3024b1d492 |
@@ -5761,36 +5761,37 @@ abstract class MetaModel
|
||||
|
||||
self::$m_sEnvironment = $sEnvironment;
|
||||
|
||||
try {
|
||||
if (!defined('MODULESROOT')) {
|
||||
define('MODULESROOT', APPROOT.'env-'.self::$m_sEnvironment.'/');
|
||||
if (!defined('MODULESROOT')) {
|
||||
define('MODULESROOT', APPROOT.'env-'.self::$m_sEnvironment.'/');
|
||||
|
||||
self::$m_bTraceSourceFiles = $bTraceSourceFiles;
|
||||
self::$m_bTraceSourceFiles = $bTraceSourceFiles;
|
||||
|
||||
// $config can be either a filename, or a Configuration object (volatile!)
|
||||
if ($config instanceof Config) {
|
||||
self::LoadConfig($config, $bAllowCache);
|
||||
} else {
|
||||
self::LoadConfig(new Config($config), $bAllowCache);
|
||||
}
|
||||
|
||||
if ($bModelOnly) {
|
||||
return;
|
||||
}
|
||||
// $config can be either a filename, or a Configuration object (volatile!)
|
||||
if ($config instanceof Config) {
|
||||
self::LoadConfig($config, $bAllowCache);
|
||||
} else {
|
||||
self::LoadConfig(new Config($config), $bAllowCache);
|
||||
}
|
||||
|
||||
CMDBSource::SelectDB(self::$m_sDBName);
|
||||
|
||||
foreach (MetaModel::EnumPlugins('ModuleHandlerApiInterface') as $oPHPClass) {
|
||||
$oPHPClass::OnMetaModelStarted();
|
||||
if ($bModelOnly) {
|
||||
// Event service must be initialized after the MetaModel startup, otherwise it cannot discover classes implementing the iEventServiceSetup interface
|
||||
EventService::InitService();
|
||||
EventService::FireEvent(new EventData(ApplicationEvents::APPLICATION_EVENT_METAMODEL_STARTED));
|
||||
return;
|
||||
}
|
||||
|
||||
ExpressionCache::Warmup();
|
||||
} finally {
|
||||
// Event service must be initialized after the MetaModel startup, otherwise it cannot discover classes implementing the iEventServiceSetup interface
|
||||
EventService::InitService();
|
||||
EventService::FireEvent(new EventData(ApplicationEvents::APPLICATION_EVENT_METAMODEL_STARTED));
|
||||
}
|
||||
|
||||
CMDBSource::SelectDB(self::$m_sDBName);
|
||||
|
||||
foreach (MetaModel::EnumPlugins('ModuleHandlerApiInterface') as $oPHPClass) {
|
||||
$oPHPClass::OnMetaModelStarted();
|
||||
}
|
||||
|
||||
ExpressionCache::Warmup();
|
||||
|
||||
// Event service must be initialized after the MetaModel startup, otherwise it cannot discover classes implementing the iEventServiceSetup interface
|
||||
EventService::InitService();
|
||||
EventService::FireEvent(new EventData(ApplicationEvents::APPLICATION_EVENT_METAMODEL_STARTED));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
namespace Combodo\iTop\Setup\FeatureRemoval;
|
||||
|
||||
use ContextTag;
|
||||
use CoreException;
|
||||
use Exception;
|
||||
use IssueLog;
|
||||
use SetupLog;
|
||||
use utils;
|
||||
@@ -31,42 +29,82 @@ class ModelReflectionSerializer
|
||||
self::$oInstance = $oInstance;
|
||||
}
|
||||
|
||||
public const ERROR_LABEL = "Data consistency check failed: %s";
|
||||
|
||||
public function GetModelFromEnvironment(string $sEnv): array
|
||||
{
|
||||
IssueLog::Debug(__METHOD__, null, ['env' => $sEnv]);
|
||||
|
||||
$sPHPExec = trim(utils::GetConfig()->Get('php_path'));
|
||||
$sOutput = "";
|
||||
$aOutput = null;
|
||||
$iRes = 0;
|
||||
|
||||
$sCommandLine = sprintf("$sPHPExec %s/get_model_reflection.php --env=%s", __DIR__, escapeshellarg($sEnv));
|
||||
exec($sCommandLine, $sOutput, $iRes);
|
||||
exec("$sPHPExec --version", $aOutput, $iRes);
|
||||
if ($iRes != 0) {
|
||||
$this->LogErrorWithProperLogger("Cannot get classes", null, ['env' => $sEnv, 'code' => $iRes, "output" => $sOutput, 'cmd' => $sCommandLine]);
|
||||
throw new CoreException("Cannot get classes from env ".$sEnv);
|
||||
$sError = sprintf(self::ERROR_LABEL, "Cannot check CLI/PHP version ($sPHPExec)");
|
||||
$this->LogSetupError($sError, null, ['env' => $sEnv, 'code' => $iRes, "output" => $aOutput, 'php_path' => $sPHPExec]);
|
||||
throw new CoreException($sError);
|
||||
}
|
||||
|
||||
$aClasses = json_decode($sOutput[0] ?? null, true);
|
||||
$this->CheckCliPhpVersionFromOutput(PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, $sPHPExec, $aOutput);
|
||||
|
||||
$aOutput = null;
|
||||
$iRes = 0;
|
||||
//preliminary check
|
||||
$sEnvDir = APPROOT."env-$sEnv";
|
||||
if (! is_dir($sEnvDir)) {
|
||||
$sMsg = sprintf(self::ERROR_LABEL, "Missing environment ($sEnvDir)");
|
||||
$this->LogSetupError($sMsg);
|
||||
throw new CoreException($sMsg);
|
||||
}
|
||||
|
||||
$sConfigFile = APPROOT."conf/$sEnv/config-itop.php";
|
||||
if (! is_file($sConfigFile)) {
|
||||
$sMsg = sprintf(self::ERROR_LABEL, "Missing configuration ($sConfigFile)");
|
||||
$this->LogSetupError($sMsg);
|
||||
throw new CoreException($sMsg);
|
||||
}
|
||||
|
||||
$sCommandLine = sprintf("$sPHPExec %s/get_model_reflection.php --env=%s", __DIR__, escapeshellarg($sEnv));
|
||||
exec($sCommandLine, $aOutput, $iRes);
|
||||
if ($iRes != 0) {
|
||||
$sError = $aOutput[0] ?? 'Invalid output when serializing model';
|
||||
$this->LogSetupError(sprintf(self::ERROR_LABEL, '(cli error) '.$sError), null, ['env' => $sEnv, 'code' => $iRes, "output" => $aOutput, 'cmd' => $sCommandLine]);
|
||||
throw new CoreException(sprintf(self::ERROR_LABEL, $sError));
|
||||
}
|
||||
|
||||
$aClasses = json_decode($aOutput[0] ?? null, true);
|
||||
if (false === $aClasses) {
|
||||
$this->LogErrorWithProperLogger("Invalid JSON", null, ['env' => $sEnv, "output" => $sOutput]);
|
||||
throw new Exception("cannot get classes");
|
||||
$sMsg = sprintf(self::ERROR_LABEL, 'Invalid JSON');
|
||||
$this->LogSetupError($sMsg, null, ['env' => $sEnv, "output" => $aOutput]);
|
||||
throw new CoreException($sMsg);
|
||||
}
|
||||
|
||||
if (!is_array($aClasses)) {
|
||||
$this->LogErrorWithProperLogger("not an array", null, ['env' => $sEnv, "classes" => $aClasses, "output" => $sOutput]);
|
||||
throw new Exception("cannot get classes from $sEnv");
|
||||
$sError = $aOutput[0] ?? 'Invalid json array when serializing model';
|
||||
$this->LogSetupError(sprintf(self::ERROR_LABEL, '(JSON output not an array) '.$sError), null, ['env' => $sEnv, "classes" => $aClasses, "output" => $aOutput]);
|
||||
throw new CoreException(sprintf(self::ERROR_LABEL, $sError));
|
||||
}
|
||||
|
||||
return $aClasses;
|
||||
}
|
||||
|
||||
//could be shared with others in log APIs ?
|
||||
private function LogErrorWithProperLogger($sMessage, $sChannel = null, $aContext = []): void
|
||||
public function CheckCliPhpVersionFromOutput(string $sUIPhpVersion, string $sPHPExec, $aOutput): void
|
||||
{
|
||||
if (ContextTag::Check(ContextTag::TAG_SETUP)) {
|
||||
SetupLog::Error($sMessage, $sChannel, $aContext);
|
||||
} else {
|
||||
IssueLog::Error($sMessage, $sChannel, $aContext);
|
||||
$sFoundVersion = trim($aOutput[0] ?? "");
|
||||
if (preg_match('/(\d+\.\d+)(?:\.\d+)?/', $sFoundVersion, $aMatches)) {
|
||||
$sFoundVersion = $aMatches[1];
|
||||
}
|
||||
if ($sFoundVersion != $sUIPhpVersion) {
|
||||
$sError = sprintf(self::ERROR_LABEL, "Invalid PHP versions (CLI: $sFoundVersion/ UI: $sUIPhpVersion)");
|
||||
$this->LogSetupError($sError, null, ["output" => $aOutput, 'php_path' => $sPHPExec]);
|
||||
throw new CoreException($sError);
|
||||
}
|
||||
}
|
||||
|
||||
//could be shared with others in log APIs ?
|
||||
private function LogSetupError($sMessage, $sChannel = null, $aContext = []): void
|
||||
{
|
||||
SetupLog::Enable(APPROOT.'log/setup.log');
|
||||
SetupLog::Error($sMessage, $sChannel, $aContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ $sConfFile = utils::GetConfigFilePath($sEnv);
|
||||
try {
|
||||
MetaModel::Startup($sConfFile, false /* $bModelOnly */, false /* $bAllowCache */, false /* $bTraceSourceFiles */, $sEnv);
|
||||
} catch (\Throwable $e) {
|
||||
echo $e->getMessage();
|
||||
echo $e->getTraceAsString();
|
||||
SetupLog::Enable(APPROOT.'log/setup.log');
|
||||
\SetupLog::Error(
|
||||
"Cannot read model from provided environment",
|
||||
null,
|
||||
@@ -32,7 +31,9 @@ try {
|
||||
'stack' => $e->getTraceAsString(),
|
||||
]
|
||||
);
|
||||
echo "Cannot read model from provided environment";
|
||||
|
||||
//keep first echo to have proper setup feedbacks
|
||||
echo $e->getMessage();
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -3002,6 +3002,8 @@ class SynchroExecution
|
||||
* </ul>
|
||||
*/
|
||||
protected $m_oLastFullLoadStartDate = null;
|
||||
/** @var bool true if the caller script gave the datetime before import phase was launched */
|
||||
protected $m_bIsImportPhaseDateKnown;
|
||||
|
||||
/** @var \CMDBChange */
|
||||
protected $m_oChange = null;
|
||||
@@ -3026,7 +3028,10 @@ class SynchroExecution
|
||||
public function __construct($oDataSource, $oImportPhaseStartDate = null)
|
||||
{
|
||||
$this->m_oDataSource = $oDataSource;
|
||||
|
||||
$this->m_bIsImportPhaseDateKnown = ($oImportPhaseStartDate != null);
|
||||
$this->m_oImportPhaseStartDate = $oImportPhaseStartDate;
|
||||
|
||||
$this->m_oCtx = new ContextTag(ContextTag::TAG_SYNCHRO);
|
||||
$this->m_oCtx1 = new ContextTag('Synchro:'.$oDataSource->GetRawName()); // More precise context information
|
||||
}
|
||||
@@ -3103,7 +3108,7 @@ class SynchroExecution
|
||||
$this->m_oStatLog->Set('stats_nb_replica_total', $this->m_iCountAllReplicas);
|
||||
|
||||
$this->m_oStatLog->DBInsert();
|
||||
$sLastFullLoad = (is_null($this->m_oImportPhaseStartDate)) ? 'not specified' : $this->m_oImportPhaseStartDate->format('Y-m-d H:i:s');
|
||||
$sLastFullLoad = ($this->m_bIsImportPhaseDateKnown) ? $this->m_oImportPhaseStartDate->format('Y-m-d H:i:s') : 'not specified';
|
||||
$this->m_oStatLog->AddTrace("###### STARTING SYNCHRONIZATION ##### Total: {$this->m_iCountAllReplicas} replica(s). Last full load: '$sLastFullLoad' ");
|
||||
$sSql = 'SELECT NOW();';
|
||||
$sDBNow = CMDBSource::QueryToScalar($sSql);
|
||||
@@ -3207,18 +3212,21 @@ class SynchroExecution
|
||||
// Compute and keep track of the limit date taken into account for obsoleting replicas
|
||||
//
|
||||
$iFullLoadInterval = $this->m_oDataSource->Get('full_load_periodicity'); // Duration in seconds
|
||||
if (is_null($this->m_oImportPhaseStartDate)) {
|
||||
if ($this->m_bIsImportPhaseDateKnown) {
|
||||
$oLimitDate = clone $this->m_oImportPhaseStartDate;
|
||||
$sInterval = "-$iFullLoadInterval seconds";
|
||||
$oLimitDate->Modify($sInterval);
|
||||
} else {
|
||||
if ($iFullLoadInterval <= 0) {
|
||||
// we are doing exec phase alone, and the full load interval is set to 0 => we should not update/delete replicas !!
|
||||
// This will prevent actions in DoJob1() method
|
||||
$oLimitDate = new DateTime('1970-01-01');
|
||||
} else {
|
||||
$oLimitDate = self::GetDataBaseCurrentDateTime();
|
||||
$sInterval = "-$iFullLoadInterval seconds";
|
||||
$oLimitDate->Modify($sInterval);
|
||||
}
|
||||
} else {
|
||||
$oLimitDate = clone $this->m_oImportPhaseStartDate;
|
||||
}
|
||||
$this->ExactlySubtractSeconds($oLimitDate, $iFullLoadInterval);
|
||||
$this->m_oLastFullLoadStartDate = $oLimitDate;
|
||||
if ($bFirstPass) {
|
||||
$this->m_oStatLog->AddTrace('Limit Date: '.$this->m_oLastFullLoadStartDate->Format('Y-m-d H:i:s'));
|
||||
@@ -3338,10 +3346,10 @@ class SynchroExecution
|
||||
$aArguments['log'] = $this->m_oStatLog->GetKey();
|
||||
$aArguments['change'] = $this->m_oChange->GetKey();
|
||||
$aArguments['chunk'] = $iMaxChunkSize;
|
||||
if (is_null($this->m_oImportPhaseStartDate)) {
|
||||
$aArguments['last_full_load'] = '';
|
||||
} else {
|
||||
if ($this->m_bIsImportPhaseDateKnown) {
|
||||
$aArguments['last_full_load'] = $this->m_oImportPhaseStartDate->Format('Y-m-d H:i:s');
|
||||
} else {
|
||||
$aArguments['last_full_load'] = '';
|
||||
}
|
||||
|
||||
$this->m_oStatLog->DBUpdate();
|
||||
@@ -3693,11 +3701,13 @@ class SynchroExecution
|
||||
|
||||
// Get all the replicas that are to be deleted
|
||||
//
|
||||
$oDeletionDate = clone $this->m_oLastFullLoadStartDate;
|
||||
$oDeletionDate = $this->m_oLastFullLoadStartDate;
|
||||
$iDeleteRetention = $this->m_oDataSource->Get('delete_policy_retention'); // Duration in seconds
|
||||
$this->ExactlySubtractSeconds($oDeletionDate, $iDeleteRetention);
|
||||
if ($iDeleteRetention > 0) {
|
||||
$sInterval = "-$iDeleteRetention seconds";
|
||||
$oDeletionDate->Modify($sInterval);
|
||||
}
|
||||
$sDeletionDate = $oDeletionDate->Format('Y-m-d H:i:s');
|
||||
|
||||
if ($bFirstPass) {
|
||||
$this->m_oStatLog->AddTrace("Deletion date: $sDeletionDate");
|
||||
}
|
||||
@@ -3747,21 +3757,4 @@ class SynchroExecution
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Take into account timechange to apply date difference operation
|
||||
* @param \DateTime $oDate
|
||||
* @param $iDurationInSeconds
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function ExactlySubtractSeconds(DateTime $oDate, $iDurationInSeconds): void
|
||||
{
|
||||
if ($iDurationInSeconds > 0) {
|
||||
$oDate->setTimezone(new DateTimeZone('UTC'));
|
||||
$sInterval = "-$iDurationInSeconds seconds";
|
||||
$oDate->Modify($sInterval);
|
||||
$sTimezone = MetaModel::GetConfig()->Get('timezone');
|
||||
$oDate->setTimezone(new DateTimeZone($sTimezone));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,87 @@ class ModelSerializationTest extends ItopDataTestCase
|
||||
$this->assertEqualsCanonicalizing(MetaModel::GetClasses(), $aModel);
|
||||
}
|
||||
|
||||
public function testGetModelFromEnvironmentFailure()
|
||||
public function testCheckFail()
|
||||
{
|
||||
$sOuput = <<<OUTPUT
|
||||
PHP 7.4.33 (cli) (built: Aug 2 2024 16:22:28) ( NTS )
|
||||
OUTPUT;
|
||||
|
||||
$this->expectException(\CoreException::class);
|
||||
$this->expectExceptionMessage("Data consistency check failed: Invalid PHP versions (CLI: 7.4/ UI: 6.6)");
|
||||
ModelReflectionSerializer::GetInstance()->CheckCliPhpVersionFromOutput('6.6', 'sPHPExec', [$sOuput]);
|
||||
}
|
||||
|
||||
public function CheckOKProvider()
|
||||
{
|
||||
return [
|
||||
["7.4 7.2 7.3.33"],
|
||||
["PHP 7.4.33 (cli) (built: Aug 2 2024 16:22:28) ( NTS )"],
|
||||
["PHP 7.4.33 PHP 7.33.22"],
|
||||
["version: 7.4.27 stable"],
|
||||
];
|
||||
}
|
||||
/**
|
||||
* @dataProvider CheckOKProvider
|
||||
*/
|
||||
public function testCheckOK($sOuput)
|
||||
{
|
||||
ModelReflectionSerializer::GetInstance()->CheckCliPhpVersionFromOutput('7.4', 'sPHPExec', [$sOuput]);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testGetModelFromEnvironmentFailure_NoEnvt()
|
||||
{
|
||||
$this->expectException(\CoreException::class);
|
||||
$this->expectExceptionMessage("Cannot get classes");
|
||||
$sEnvDir = APPROOT."env-gabuzomeu";
|
||||
$this->expectExceptionMessage("Data consistency check failed: Missing environment ($sEnvDir)");
|
||||
ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment('gabuzomeu');
|
||||
}
|
||||
|
||||
public function testGetModelFromEnvironmentFailure_NoConfiguration()
|
||||
{
|
||||
$sEnvDir = APPROOT."env-gabuzomeu";
|
||||
$this->aFileToClean [] = $sEnvDir;
|
||||
mkdir($sEnvDir);
|
||||
|
||||
$this->expectException(\CoreException::class);
|
||||
$sConfigFile = APPROOT."conf/gabuzomeu/config-itop.php";
|
||||
$this->expectExceptionMessage("Data consistency check failed: Missing configuration ($sConfigFile)");
|
||||
ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment('gabuzomeu');
|
||||
}
|
||||
|
||||
public function testGetModelFromEnvironmentFailure_BrokenConfiguration()
|
||||
{
|
||||
$sEnvDir = APPROOT."env-gabuzomeu";
|
||||
mkdir($sEnvDir);
|
||||
$this->aFileToClean [] = $sEnvDir;
|
||||
|
||||
mkdir(APPROOT."conf/gabuzomeu");
|
||||
$this->aFileToClean [] = APPROOT."conf/gabuzomeu";
|
||||
$sConfigFile = APPROOT."conf/gabuzomeu/config-itop.php";
|
||||
touch($sConfigFile);
|
||||
file_put_contents($sConfigFile, 'invalid php content...');
|
||||
|
||||
$this->expectException(\CoreException::class);
|
||||
$sError = <<<ERROR
|
||||
Syntax error in configuration file: file = $sConfigFile, error = <tt>invalid php content...</tt>
|
||||
ERROR;
|
||||
|
||||
$this->expectExceptionMessage("Data consistency check failed: $sError");
|
||||
ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment('gabuzomeu');
|
||||
}
|
||||
|
||||
public function testGetModelFromEnvironmentFailure_ItopInMaintenanceMode()
|
||||
{
|
||||
touch(MAINTENANCE_MODE_FILE);
|
||||
$this->aFileToClean [] = MAINTENANCE_MODE_FILE;
|
||||
|
||||
$this->expectException(\CoreException::class);
|
||||
$sError = <<<ERROR
|
||||
This application is currently under maintenance.
|
||||
ERROR;
|
||||
|
||||
$this->expectExceptionMessage("Data consistency check failed: $sError");
|
||||
ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment($this->GetTestEnvironment());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
|
||||
|
||||
class SynchroExecutionTest extends ItopDataTestCase
|
||||
{
|
||||
private function InitAndGetTZ($sTZ = null): string
|
||||
{
|
||||
$sTZ = $sTZ ?? 'Europe/Paris';
|
||||
MetaModel::GetConfig()->Set('timezone', $sTZ);
|
||||
return $sTZ;
|
||||
}
|
||||
|
||||
public function testGetDateMinusRetentionWithTimeChange()
|
||||
{
|
||||
$sTZ = $this->InitAndGetTZ();
|
||||
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
|
||||
$oDate = DateTime::createFromFormat('Y-m-d H:i:s', "2026-03-29 03:30:01", new DateTimeZone($sTZ));
|
||||
|
||||
$oObj->ExactlySubtractSeconds($oDate, 3600);
|
||||
//03h30 minus 1hours => 03h minus 30mn => 03h
|
||||
// => 03h with timechange => 2h
|
||||
// => 02 minus 30mn => 01h30
|
||||
$this->assertEquals("2026-03-29 01:30:01", $oDate->Format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testGetDateMinusRetentioLinuxTimeZero()
|
||||
{
|
||||
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
|
||||
$oDate = new DateTime('1970-01-01');
|
||||
|
||||
$oObj->ExactlySubtractSeconds($oDate, 3600);
|
||||
$this->assertEquals("1969-12-31 23:00:00", $oDate->Format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testGetDateMinusRetentionWithTimeChangeAndUTC()
|
||||
{
|
||||
$sTZ = $this->InitAndGetTZ('UTC');
|
||||
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
|
||||
$oDate = DateTime::createFromFormat('Y-m-d H:i:s', "2026-03-29 03:30:01", new DateTimeZone($sTZ));
|
||||
|
||||
$oObj->ExactlySubtractSeconds($oDate, 3600);
|
||||
$this->assertEquals("2026-03-29 02:30:01", $oDate->Format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testGetDateMinusRetention()
|
||||
{
|
||||
$sTZ = $this->InitAndGetTZ();
|
||||
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
|
||||
$oDate = DateTime::createFromFormat('Y-m-d H:i:s', "2026-04-01 03:30:01", new DateTimeZone($sTZ));
|
||||
|
||||
$oObj->ExactlySubtractSeconds($oDate, 3600);
|
||||
$this->assertEquals("2026-04-01 02:30:01", $oDate->Format('Y-m-d H:i:s'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user