Compare commits

..

10 Commits

Author SHA1 Message Date
denis.flaven@combodo.com
3fb5c05467 N°7001 - Being admin to generate oAuth token is too restrictive 2023-12-13 13:57:44 +01:00
Pierre Goiffon
f36f3aa05b 💡 N°6848 Fix PHPDoc 2023-10-17 11:12:38 +02:00
Pierre Goiffon
d0d90d7c69 N°6848 Fix TransactionsTest::testTransactionOpenedNotClosed failing
Was caused by 239c51bb, which adds 65bb76b9 tests improvements but only partly : we were missing in TransactionsTest::tearDown the mysqli reset from mock to original mysqli cnx

Without this reset the rollback made in ItopTestCase::tearDown is throwing an exception (query() method on the DbConnectionWrapper cnx returns false in \CMDBSource::DBQuery) on PHP 8.1 and 8.2

Co-authored-by: Romain Quetiez <romain.queriez@combodo.com>
2023-10-17 10:53:26 +02:00
odain
239c51bb53 ci enhancement: complete tearDown to cleanup transactions and cmdb changes properly 2023-10-03 10:09:26 +02:00
Pierre Goiffon
bdf0b4daa9 N°6562 Fix DOMNode->textContent write
This attribute is read only
Causes layout issues on PHP 8.1.21 and 8.2.8

(cherry picked from commit 734a788340)
(cherry picked from commit 7aa478d6ff)
2023-09-14 15:31:02 +02:00
Eric Espie
7fdbb59c30 N°6716 - High memory Consomption and performance issue 2023-09-14 14:09:05 +02:00
Eric Espie
5acf38ac36 Fix unit tests for MariaDB
(cherry picked from commit 61a9a4ac65)
2023-09-14 14:09:05 +02:00
Molkobain
85f66f5e0c N°6618 - Router: Add protection against invalid routes cache 2023-08-02 11:44:20 +02:00
Molkobain
a5c980113b N°6618 - Router: Fix available routes cache being re-generated at each call 2023-08-02 11:44:20 +02:00
Eric Espie
df1cb0b6e3 N°6408 - CRUD : Fix log consuming too much memory 2023-07-12 11:21:32 +02:00
16 changed files with 367 additions and 31 deletions

View File

@@ -59,9 +59,17 @@ class DbConnectionWrapper
* Use this to register a mock that will handle {@see mysqli::query()}
*
* @param \mysqli|null $oMysqli
* @since 3.0.4 3.1.1 3.2.0 Param $oMysqli becomes nullable
* @since 3.1.0-4 N°6848 backport of restoring cnx on null parameter value
*/
public static function SetDbConnectionMockForQuery(?mysqli $oMysqli): void
public static function SetDbConnectionMockForQuery(?mysqli $oMysqli = null): void
{
static::$oDbCnxMockableForQuery = $oMysqli;
if (is_null($oMysqli)) {
// Reset to standard connection
static::$oDbCnxMockableForQuery = static::$oDbCnxStandard;
}
else {
static::$oDbCnxMockableForQuery = $oMysqli;
}
}
}

View File

@@ -6,7 +6,9 @@
use Combodo\iTop\Core\MetaModel\FriendlyNameType;
use Combodo\iTop\Service\Events\EventData;
use Combodo\iTop\Service\Events\EventException;
use Combodo\iTop\Service\Events\EventService;
use Combodo\iTop\Service\Events\EventServiceLog;
use Combodo\iTop\Service\TemporaryObjects\TemporaryObjectManager;
/**
@@ -203,6 +205,8 @@ abstract class DBObject implements iDisplay
const MAX_UPDATE_LOOP_COUNT = 10;
private $aEventListeners = [];
/**
* DBObject constructor.
*
@@ -255,6 +259,10 @@ abstract class DBObject implements iDisplay
$this->RegisterEventListeners();
}
/**
* @see RegisterCRUDListener
* @see EventService::RegisterListener()
*/
protected function RegisterEventListeners()
{
}
@@ -612,8 +620,11 @@ abstract class DBObject implements iDisplay
public function Set($sAttCode, $value)
{
if (!utils::StartsWith(get_class($this), 'CMDBChange') && $this->GetKey() > 0) {
// not all the values have __to_string() so print_r is sed and preferred over var_export for the handling or circular references
$this->LogCRUDEnter(__METHOD__, "$sAttCode => ".print_r($value, true));
if (is_object($value) || is_array($value)) {
$this->LogCRUDEnter(__METHOD__, "$sAttCode => object or array");
} else {
$this->LogCRUDEnter(__METHOD__, "$sAttCode => ".print_r($value, true));
}
}
$sMessage = $this->IsReadOnly();
@@ -6162,6 +6173,51 @@ abstract class DBObject implements iDisplay
return OPT_ATT_NORMAL;
}
public final function GetListeners(): array
{
$aListeners = [];
foreach ($this->aEventListeners as $aEventListener) {
$aListeners = array_merge($aListeners, $aEventListener);
}
return $aListeners;
}
/**
* Register a callback for a specific event. The method to call will be saved in the object instance itself whereas calling {@see EventService::RegisterListener()} would
* save a callable (thus the method name AND the whole DBObject instance)
*
* @param string $sEvent corresponding event
* @param string $callback The callback method to call
* @param float $fPriority optional priority for callback order
* @param string $sModuleId
*
* @see EventService::RegisterListener()
*
* @since 3.1.0-3 3.1.1 3.2.0 N°6716
*/
final protected function RegisterCRUDListener(string $sEvent, string $callback, float $fPriority = 0.0, string $sModuleId = '')
{
$aEventCallbacks = $this->aEventListeners[$sEvent] ?? [];
$aEventCallbacks[] = array(
'event' => $sEvent,
'callback' => $callback,
'priority' => $fPriority,
'module' => $sModuleId,
);
usort($aEventCallbacks, function ($a, $b) {
$fPriorityA = $a['priority'];
$fPriorityB = $b['priority'];
if ($fPriorityA == $fPriorityB) {
return 0;
}
return ($fPriorityA < $fPriorityB) ? -1 : 1;
});
$this->aEventListeners[$sEvent] = $aEventCallbacks;
}
/**
* @param string $sEvent
* @param array $aEventData
@@ -6173,15 +6229,53 @@ abstract class DBObject implements iDisplay
*/
public function FireEvent(string $sEvent, array $aEventData = array()): void
{
if (EventService::IsEventRegistered($sEvent)) {
$aEventData['debug_info'] = 'from: '.get_class($this).':'.$this->GetKey();
$aEventData['object'] = $this;
$aEventSources = [$this->m_sObjectUniqId];
foreach (MetaModel::EnumParentClasses(get_class($this), ENUM_PARENT_CLASSES_ALL, false) as $sClass) {
$aEventSources[] = $sClass;
$aEventData['debug_info'] = 'from: '.get_class($this).':'.$this->GetKey();
$aEventData['object'] = $this;
// Call local listeners first
$aEventCallbacks = $this->aEventListeners[$sEvent] ?? [];
$oFirstException = null;
$sFirstExceptionMessage = '';
foreach ($aEventCallbacks as $aEventCallback) {
$oKPI = new ExecutionKPI();
$sCallback = $aEventCallback['callback'];
if (!method_exists($this, $sCallback)) {
EventServiceLog::Error("Callback '".get_class($this).":$sCallback' does not exist");
continue;
}
EventServiceLog::Debug("Fire event '$sEvent' calling '".get_class($this).":$sCallback'");
try {
call_user_func([$this, $sCallback], new EventData($sEvent, null, $aEventData));
}
catch (EventException $e) {
EventServiceLog::Error("Event '$sEvent' for '$sCallback'} failed with blocking error: ".$e->getMessage());
throw $e;
}
catch (Exception $e) {
$sMessage = "Event '$sEvent' for '$sCallback'} failed with non-blocking error: ".$e->getMessage();
EventServiceLog::Error($sMessage);
if (is_null($oFirstException)) {
$sFirstExceptionMessage = $sMessage;
$oFirstException = $e;
}
}
finally {
$oKPI->ComputeStats('FireEvent', $sEvent);
}
EventService::FireEvent(new EventData($sEvent, $aEventSources, $aEventData));
}
if (!is_null($oFirstException)) {
throw new Exception($sFirstExceptionMessage, $oFirstException->getCode(), $oFirstException);
}
// Call global event listeners
if (!EventService::IsEventRegistered($sEvent)) {
return;
}
$aEventSources = [];
foreach (MetaModel::EnumParentClasses(get_class($this), ENUM_PARENT_CLASSES_ALL, false) as $sClass) {
$aEventSources[] = $sClass;
}
EventService::FireEvent(new EventData($sEvent, $aEventSources, $aEventData));
}
//////////////////

View File

@@ -10,6 +10,5 @@ IssueLog::Trace('----- Request: '.utils::GetRequestUri(), LogChannels::WEB_REQUE
$sTemplates = APPROOT.'templates/pages/backoffice/oauth';
$oUpdateController = new OAuthLandingController($sTemplates, 'core');
$oUpdateController->AllowOnlyAdmin();
$oUpdateController->SetDefaultOperation('Landing');
$oUpdateController->HandleOperation();

View File

@@ -290,7 +290,6 @@ function DisplayEvents(WebPage $oPage, $sClass)
foreach (MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL) as $sChildClass) {
if (!MetaModel::IsAbstract($sChildClass)) {
$oObject = MetaModel::NewObject($sChildClass);
$aSources[] = $oObject->GetObjectUniqId();
break;
}
}
@@ -299,7 +298,6 @@ function DisplayEvents(WebPage $oPage, $sClass)
}
} else {
$oObject = MetaModel::NewObject($sClass);
$aSources[] = $oObject->GetObjectUniqId();
foreach (MetaModel::EnumParentClasses($sClass, ENUM_PARENT_CLASSES_ALL, false) as $sParentClass) {
$aSources[] = $sParentClass;
}
@@ -320,12 +318,19 @@ function DisplayEvents(WebPage $oPage, $sClass)
});
$aColumns = [
'event' => ['label' => Dict::S('UI:Schema:Events:Event')],
'listener' => ['label' => Dict::S('UI:Schema:Events:Listener')],
'callback' => ['label' => Dict::S('UI:Schema:Events:Listener')],
'priority' => ['label' => Dict::S('UI:Schema:Events:Rank')],
'module' => ['label' => Dict::S('UI:Schema:Events:Module')],
];
// Get the object listeners first
$aRows = [];
$oReflectionClass = new ReflectionClass($sClass);
if ($oReflectionClass->isInstantiable()) {
/** @var DBObject $oClass */
$oClass = new $sClass();
$aRows = $oClass->GetListeners();
}
foreach ($aListeners as $aListener) {
if (is_object($aListener['callback'][0])) {
$sListenerClass = $sClass;
@@ -343,7 +348,7 @@ function DisplayEvents(WebPage $oPage, $sClass)
}
$aRows[] = [
'event' => $aListener['event'],
'listener' => $sListener,
'callback' => $sListener,
'priority' => $aListener['priority'],
'module' => $aListener['module'],
];

View File

@@ -1449,17 +1449,12 @@ EOF
}
$sMethods .= "\n $sCallbackFct\n\n";
}
if (strpos($sCallback, '::') === false) {
$sEventListener = '[$this, \''.$sCallback.'\']';
} else {
$sEventListener = "'$sCallback'";
}
$sListenerRank = (float)($oListener->GetChildText('rank', '0'));
$sEvents .= <<<PHP
// listenerId = $sListenerId
Combodo\iTop\Service\Events\EventService::RegisterListener("$sEventName", $sEventListener, \$this->m_sObjectUniqId, [], null, $sListenerRank, '$sModuleRelativeDir');
\$this->RegisterCRUDListener("$sEventName", '$sCallback', $sListenerRank, '$sModuleRelativeDir');
PHP;
}
}

View File

@@ -892,7 +892,10 @@ class iTopDesignFormat
$oNodeList = $oXPath->query("/itop_design/classes//class/fields/field/values/value");
foreach ($oNodeList as $oNode) {
$sCode = $oNode->textContent;
$oNode->textContent = '';
// N°6562 textContent is readonly, see https://www.php.net/manual/en/class.domnode.php#95545
// $oNode->textContent = '';
// N°6562 to update text node content we must use the node methods !
$oNode->removeChild($oNode->firstChild);
$oCodeNode = $oNode->ownerDocument->createElement("code", $sCode);
$oNode->appendChild($oCodeNode);
}
@@ -982,7 +985,12 @@ class iTopDesignFormat
if ($oStyleNode) {
$this->DeleteNode($oStyleNode);
}
$oNode->textContent = $sCode;
// N°6562 textContent is readonly, see https://www.php.net/manual/en/class.domnode.php#95545
// $oNode->textContent = $sCode;
// N°6562 to update text node content we must use the node methods !
$oTextContentNode = new DOMText($sCode);
$oNode->appendChild($oTextContentNode);
}
}
// - Style

View File

@@ -10,6 +10,7 @@ use Closure;
use Combodo\iTop\Service\Events\Description\EventDescription;
use ContextTag;
use CoreException;
use DBObject;
use Exception;
use ExecutionKPI;
use ReflectionClass;
@@ -53,6 +54,12 @@ final class EventService
/**
* Register a callback for a specific event
*
* **Warning** : be ultra careful on memory footprint ! each callback will be saved in {@see aEventListeners}, and a callback is
* made of the whole object instance and the method name ({@link https://www.php.net/manual/en/language.types.callable.php}).
* For example to register on DBObject instances, you should better use {@see DBObject::RegisterCRUDListener()}
*
* @uses aEventListeners
*
* @api
* @param string $sEvent corresponding event
* @param callable $callback The callback to call
@@ -61,8 +68,12 @@ final class EventService
* @param array|string|null $context context filter
* @param float $fPriority optional priority for callback order
*
* @return string Id of the registration
* @return string registration identifier
*
* @see DBObject::RegisterCRUDListener() to register in DBObject instances instead, to reduce memory footprint (callback saving)
*
* @since 3.1.0 method creation
* @since 3.1.0-3 3.1.1 3.2.0 N°6716 PHPDoc change to warn on memory footprint, and {@see DBObject::RegisterCRUDListener()} alternative
*/
public static function RegisterListener(string $sEvent, callable $callback, $sEventSource = null, array $aCallbackData = [], $context = null, float $fPriority = 0.0, $sModuleId = ''): string
{

View File

@@ -138,12 +138,24 @@ class Router
{
$aRoutes = [];
$bUseCache = false === utils::IsDevelopmentEnvironment();
$bMustWriteCache = false;
$sCacheFilePath = $this->GetCacheFileAbsPath();
// Try to read from cache
if ($bUseCache) {
if (is_file($sCacheFilePath)) {
$aRoutes = include $sCacheFilePath;
$aCachedRoutes = include $sCacheFilePath;
// N°6618 - Protection against corrupted cache returning `1` instead of an array of routes
if (is_array($aCachedRoutes)) {
$aRoutes = $aCachedRoutes;
} else {
// Invalid cache force re-generation
// Note that even if it is re-generated corrupted again, this protection should prevent crashes
$bMustWriteCache = true;
}
} else {
$bMustWriteCache = true;
}
}
@@ -180,11 +192,11 @@ class Router
}
}
// Save to cache
if ($bUseCache) {
// Save to cache if it doesn't exist already
if ($bMustWriteCache) {
$sCacheContent = "<?php\n\nreturn ".var_export($aRoutes, true).";";
SetupUtils::builddir(dirname($sCacheFilePath));
file_put_contents($sCacheFilePath, $sCacheContent);
file_put_contents($sCacheFilePath, $sCacheContent, LOCK_EX);
}
return $aRoutes;

View File

@@ -136,6 +136,8 @@ class ItopDataTestCase extends ItopTestCase
}
}
CMDBObject::SetCurrentChange(null);
parent::tearDown();
}

View File

@@ -68,6 +68,10 @@ class ItopTestCase extends TestCase
if (CMDBSource::IsInsideTransaction()) {
// Nested transactions were opened but not finished !
// Rollback to avoid side effects on next tests
while (CMDBSource::IsInsideTransaction()) {
CMDBSource::Query('ROLLBACK');
}
throw new MySQLTransactionNotClosedException('Some DB transactions were opened but not closed ! Fix the code by adding ROLLBACK or COMMIT statements !', []);
}
}
@@ -312,4 +316,4 @@ class ItopTestCase extends TestCase
}
closedir($dir);
}
}
}

View File

@@ -276,6 +276,7 @@ class TransactionsTest extends ItopTestCase
protected function tearDown(): void
{
try {
DbConnectionWrapper::SetDbConnectionMockForQuery(); // Else will throw error on PHP 8.1+ (see N°6848)
parent::tearDown();
}
catch (MySQLTransactionNotClosedException $e) {

View File

@@ -12,6 +12,7 @@ use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use ContactType;
use CoreException;
use DBObject;
use DBObject\MockDBObjectWithCRUDEventListener;
use DBObjectSet;
use DBSearch;
use lnkPersonToTeam;
@@ -521,6 +522,24 @@ class CRUDEventTest extends ItopDataTestCase
$this->assertEquals(2, self::$aEventCalls[EVENT_DB_LINKS_CHANGED]);
}
// Tests with MockDBObject
public function testFireCRUDEvent()
{
$this->RequireOnceUnitTestFile('DBObject/MockDBObjectWithCRUDEventListener.php');
// For Metamodel list of classes
MockDBObjectWithCRUDEventListener::Init();
$oDBObject = new MockDBObjectWithCRUDEventListener();
$oDBObject2 = new MockDBObjectWithCRUDEventListener();
$oDBObject->FireEvent(MockDBObjectWithCRUDEventListener::TEST_EVENT);
$this->assertNotNull($oDBObject->oEventDataReceived);
$this->assertNull($oDBObject2->oEventDataReceived);
//echo($oDBObject->oEventDataReceived->Get('debug_info'));
}
}
/**

View File

@@ -0,0 +1,44 @@
<?php
/**
* @copyright Copyright (C) 2010-2023 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace DBObject;
use Combodo\iTop\Service\Events\EventData;
use MetaModel;
class MockDBObjectWithCRUDEventListener extends \DBObject
{
const TEST_EVENT = 'test_event';
public $oEventDataReceived = null;
public static function Init()
{
$aParams = array
(
'category' => 'bizmodel, searchable',
'key_type' => 'autoincrement',
'name_attcode' => '',
'state_attcode' => '',
'reconc_keys' => [],
'db_table' => 'priv_unit_tests_mock',
'db_key_field' => 'id',
'db_finalclass_field' => '',
'display_template' => '',
'indexes' => [],
);
MetaModel::Init_Params($aParams);
}
protected function RegisterEventListeners()
{
$this->RegisterCRUDListener(self::TEST_EVENT, 'TestEventCallback', 0, 'unit-test');
}
public function TestEventCallback(EventData $oEventData)
{
$this->oEventDataReceived = $oEventData;
}
}

View File

@@ -897,4 +897,40 @@ class DBObjectTest extends ItopDataTestCase
return $oPerson;
}
/**
* @since 3.1.0-3 3.1.1 3.2.0 N°6716 test creation
*/
public function testConstructorMemoryFootprint():void
{
$idx = 0;
$fStart = microtime(true);
$fStartLoop = $fStart;
$iInitialPeak = 0;
$iMaxAllowedMemoryIncrease = 1 * 1024 * 1024;
for ($i = 0; $i < 5000; $i++) {
/** @noinspection PhpUnusedLocalVariableInspection We intentionally use a reference that will disappear on each loop */
$oPerson = new \Person();
if (0 == ($idx % 100)) {
$fDuration = microtime(true) - $fStartLoop;
$iMemoryPeakUsage = memory_get_peak_usage();
if ($iInitialPeak === 0) {
$iInitialPeak = $iMemoryPeakUsage;
$sInitialPeak = \utils::BytesToFriendlyFormat($iInitialPeak, 4);
}
$sCurrPeak = \utils::BytesToFriendlyFormat($iMemoryPeakUsage, 4);
echo "$idx ".sprintf('%.1f ms', $fDuration * 1000)." - Peak Memory Usage: $sCurrPeak\n";
$this->assertTrue(($iMemoryPeakUsage - $iInitialPeak) <= $iMaxAllowedMemoryIncrease , "Peak memory changed from $sInitialPeak to $sCurrPeak after $i loops");
$fStartLoop = microtime(true);
}
$idx++;
}
$fTotalDuration = microtime(true) - $fStart;
echo 'Total duration: '.sprintf('%.3f s', $fTotalDuration)."\n\n";
}
}

View File

@@ -546,7 +546,7 @@ class ExpressionEvaluateTest extends iTopDataTestCase
$oDate = new DateTime($sStartDate);
for ($i = 0 ; $i < $iRepeat ; $i++)
{
$sDate = date_format($oDate, 'Y-m-d, H:i:s');
$sDate = date_format($oDate, 'Y-m-d H:i:s');
$this->debug("Checking '$sDate'");
$this->testEveryTimeFormat($sDate);
$oDate->add(new DateInterval($sInterval));

View File

@@ -20,6 +20,16 @@ use utils;
*/
class RouterTest extends ItopTestCase
{
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->RequireOnceItopFile('setup/setuputils.class.inc.php');
}
/**
* @covers \Combodo\iTop\Service\Router\Router::GenerateUrl
* @dataProvider GenerateUrlProvider
@@ -169,6 +179,94 @@ class RouterTest extends ItopTestCase
$this->assertEquals($bShouldBePresent, $bIsPresent, "Route '$sRoute' was not expected amongst the available routes.");
}
/**
* @covers \Combodo\iTop\Service\Router\Router::GetRoutes
* @return void
*
* @since N°6618 Covers that the cache isn't re-generated at each call of the GetRoutes method
*/
public function testGetRoutesCacheGeneratedOnlyOnce(): void
{
$oRouter = Router::GetInstance();
$sRoutesCacheFilePath = $this->InvokeNonPublicMethod(Router::class, 'GetCacheFileAbsPath', $oRouter, []);
// Developer mode must be disabled for the routes cache to be used
$oConf = utils::GetConfig();
$mDeveloperModePreviousValue = $oConf->Get('developer_mode.enabled');
$oConf->Set('developer_mode.enabled', false);
// Generate cache for first time
$this->InvokeNonPublicMethod(Router::class, 'GetRoutes', $oRouter, []);
// Check that file exists and retrieve modification timestamp
if (false === is_file($sRoutesCacheFilePath)) {
$this->fail("Cache file was not generated ($sRoutesCacheFilePath)");
}
clearstatcache();
$iFirstModificationTimestamp = filemtime($sRoutesCacheFilePath);
$this->debug("Initial timestamp: $iFirstModificationTimestamp");
// Wait for just 1s to ensure timestamps would be different is the file is re-generated
sleep(1);
// Call GetRoutes() again to see if cache gets re-generated or not
$this->InvokeNonPublicMethod(Router::class, 'GetRoutes', $oRouter, []);
// Check that file still exists and that modification timestamp has not changed
if (false === is_file($sRoutesCacheFilePath)) {
$this->fail("Cache file is no longer present, that should not happen! ($sRoutesCacheFilePath)");
}
clearstatcache();
$iSecondModificationTimestamp = filemtime($sRoutesCacheFilePath);
$this->debug("Second timestamp: $iSecondModificationTimestamp");
$this->assertSame($iFirstModificationTimestamp, $iSecondModificationTimestamp, "Cache file timestamp changed, seems like cache is not working and was re-generated when it should not!");
// Restore previous value for following tests
$oConf->Set('developer_mode.enabled', $mDeveloperModePreviousValue);
}
/**
* @covers \Combodo\iTop\Service\Router\Router::GetRoutes
* @return void
*
* @since N°6618 Covers that the cache is re-generated correctly if corrupted
*/
public function testGetRoutesCacheRegeneratedCorrectlyIfCorrupted(): void
{
$oRouter = Router::GetInstance();
$sRoutesCacheFilePath = $this->InvokeNonPublicMethod(Router::class, 'GetCacheFileAbsPath', $oRouter, []);
// Developer mode must be disabled for the routes cache to be used
$oConf = utils::GetConfig();
$mDeveloperModePreviousValue = $oConf->Get('developer_mode.enabled');
$oConf->Set('developer_mode.enabled', false);
// Generate corrupted cache manually
$sFaultyStatement = 'return 1;';
file_put_contents($sRoutesCacheFilePath, <<<PHP
<?php
{$sFaultyStatement}
PHP
);
// Retrieve routes to access / fix cache in the process
$aRoutes = $this->InvokeNonPublicMethod(Router::class, 'GetRoutes', $oRouter, []);
// Check that routes are an array
$this->assertTrue(is_array($aRoutes));
// Check that file content doesn't contain `return 1`
clearstatcache();
$this->assertStringNotContainsString($sFaultyStatement, file_get_contents($sRoutesCacheFilePath), "Cache file still contains the faulty statement ($sFaultyStatement)");
// Restore previous value for following tests
$oConf->Set('developer_mode.enabled', $mDeveloperModePreviousValue);
}
public function GetRoutesProvider(): array
{
return [