testCaseClass = get_called_class();
if (!array_key_exists('loadRealTranslations', $fixture->extraTestEnvVars)) {
$fixture->extraTestEnvVars['loadRealTranslations'] = true; // load real translations by default for system tests
}
$fixture->extraDefinitions = static::provideContainerConfigBeforeClass();
try {
$fixture->performSetUp();
} catch (Exception $e) {
static::fail("Failed to setup fixture: " . $e->getMessage() . "\n" . $e->getTraceAsString());
}
}
public static function tearDownAfterClass(): void
{
Log::debug("Tearing down " . get_called_class());
if (!isset(static::$fixture)) {
$fixture = new Fixture();
} else {
$fixture = static::$fixture;
}
$fixture->performTearDown();
}
/**
* Returns true if continuous integration running this request
* Useful to exclude tests which may fail only on this setup
*/
public static function isTravisCI()
{
$travis = getenv('TRAVIS');
return !empty($travis);
}
public static function isMysqli()
{
return getenv('MYSQL_ADAPTER') == 'MYSQLI';
}
/**
* Return 4 Api Urls for testing scheduled reports :
* - one in HTML format with all available reports
* - one in PDF format with all available reports
* - two in SMS (one for each available report: MultiSites.getOne & MultiSites.getAll)
*
* @param string $dateTime eg '2010-01-01 12:34:56'
* @param string $period eg 'day', 'week', 'month', 'year'
* @return array
*/
protected static function getApiForTestingScheduledReports($dateTime, $period)
{
$apiCalls = array();
// HTML Scheduled Report
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_html_tables_only',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'html',
'otherRequestParameters' => array(
'idReport' => 1,
'reportFormat' => ReportRenderer::HTML_FORMAT,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
// CSV Scheduled Report
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_in_csv',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'csv',
'otherRequestParameters' => array(
'idReport' => 1,
'reportFormat' => ReportRenderer::CSV_FORMAT,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
// TSV Scheduled Report
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_in_tsv',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'tsv',
'otherRequestParameters' => array(
'idReport' => 1,
'reportFormat' => ReportRenderer::TSV_FORMAT,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
if (Fixture::canImagesBeIncludedInScheduledReports()) {
// PDF Scheduled Report
// tests/PHPUnit/System/processed/test_ecommerceOrderWithItems_scheduled_report_in_pdf_tables_only__ScheduledReports.generateReport_week.original.pdf
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_in_pdf_tables_only',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'pdf',
'otherRequestParameters' => array(
'idReport' => 1,
'reportFormat' => ReportRenderer::PDF_FORMAT,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
}
// SMS Scheduled Report, one site
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_via_sms_one_site',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'sms.txt',
'otherRequestParameters' => array(
'idReport' => 2,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
// SMS Scheduled Report, all sites
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_via_sms_all_sites',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'sms.txt',
'otherRequestParameters' => array(
'idReport' => 3,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
if (Fixture::canImagesBeIncludedInScheduledReports()) {
// HTML Scheduled Report with images
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_html_tables_and_graph',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'html',
'otherRequestParameters' => array(
'idReport' => 4,
'reportFormat' => ReportRenderer::HTML_FORMAT,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
// mail report with one row evolution based png graph
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_html_row_evolution_graph',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'html',
'otherRequestParameters' => array(
'idReport' => 5,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
// row evolution w/ custom previousN
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_html_row_evolution_prevCustomN',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'html',
'otherRequestParameters' => array(
'idReport' => 6,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
// row evolution w/ each in period
array_push(
$apiCalls,
array(
'ScheduledReports.generateReport',
array(
'testSuffix' => '_schedrep_html_row_evolution_overEach',
'date' => $dateTime,
'periods' => array($period),
'format' => 'original',
'fileExtension' => 'html',
'otherRequestParameters' => array(
'idReport' => 7,
'outputType' => \Piwik\Plugins\ScheduledReports\API::OUTPUT_RETURN,
'serialize' => 0,
)
)
)
);
}
return $apiCalls;
}
/**
* While {@link runApiTests()} lets you run test for many API methods at once this one tests only one specific
* API method and it goes via HTTP. While the other method lets you test only some methods starting with 'get'
* this one lets you actually test any API method.
*/
protected function runAnyApiTest($apiMethod, $apiId, $requestParams, $options = array())
{
$requestParams['module'] = 'API';
if (empty($requestParams['format'])) {
$requestParams['format'] = 'XML';
}
$requestParams['method'] = $apiMethod;
$apiId = $apiMethod . '_' . $apiId . '.' . strtolower($requestParams['format']);
$testName = 'test_' . static::getOutputPrefix();
if (!empty($options['testSuffix'])) {
$testName .= '_' . $options['testSuffix'];
}
list($processedFilePath, $expectedFilePath) =
$this->getProcessedAndExpectedPaths($testName, $apiId, $format = null, $compareAgainst = false);
if (!array_key_exists('token_auth', $requestParams)) {
$requestParams['token_auth'] = Fixture::getTokenAuth();
}
$response = $this->getResponseFromHttpAPI($requestParams);
$processedResponse = new Response($response, $options, $requestParams);
if (empty($compareAgainst)) {
$processedResponse->save($processedFilePath);
}
$response = $processedResponse->getResponseText();
if (strpos($response, 'assertValidXML($response);
}
try {
$expectedResponse = Response::loadFromFile($expectedFilePath, $options, $requestParams);
} catch (Exception $ex) {
$this->handleMissingExpectedFile($expectedFilePath, $processedResponse);
return;
}
try {
$errorMessage = get_class($this) . ": Differences with expected in '$processedFilePath'";
Response::assertEquals($expectedResponse, $processedResponse, $errorMessage);
} catch (Exception $ex) {
$this->comparisonFailures[] = $ex;
}
$this->printApiTestFailures();
}
protected function assertValidXML($xml)
{
libxml_use_internal_errors(true);
$sxe = simplexml_load_string($xml);
if ($sxe === false) {
$errors = [];
foreach (libxml_get_errors() as $error) {
$errors[] = trim($error->message) . ' @' . $error->line . ':' . $error->column;
}
static::fail('Response is no valid xml: ' . implode("\n", $errors));
}
libxml_clear_errors();
}
/**
* @param $requestUrl
* @return string
* @throws Exception
*/
protected function getResponseFromHttpAPI($requestUrl)
{
$queryString = Url::getQueryStringFromParameters($requestUrl);
$hostAndPath = Fixture::getTestRootUrl();
$url = $hostAndPath . '?' . $queryString;
$response = Http::sendHttpRequest($url, $timeout = 300);
return $response;
}
protected function _testApiUrl($testName, $apiId, $requestUrl, $compareAgainst, $params = array())
{
Manager::getInstance()->deleteAll(); // clearing the datatable cache here GREATLY speeds up system tests on travis CI
list($processedFilePath, $expectedFilePath) =
$this->getProcessedAndExpectedPaths($testName, $apiId, $format = null, $compareAgainst);
$originalGET = $_GET;
$_GET = $requestUrl;
unset($_GET['serialize']);
$onlyCheckUnserialize = !empty($params['onlyCheckUnserialize']);
$processedResponse = Response::loadFromApi($params, $requestUrl, $normailze = !$onlyCheckUnserialize);
if (empty($compareAgainst)) {
$processedResponse->save($processedFilePath);
}
$response = $processedResponse->getResponseText();
if (strpos($response, 'assertValidXML($response);
}
if ($onlyCheckUnserialize) {
if (empty($response) || is_numeric($response)) {
return; // pass
}
// check the data can be successfully unserialized, nothing else
try {
$unserialized = Common::safe_unserialize($response, [
DataTable::class,
DataTable\Simple::class,
DataTable\Row::class,
DataTable\Map::class,
Site::class,
Date::class,
Period::class,
Period\Day::class,
Period\Week::class,
Period\Month::class,
Period\Year::class,
Period\Range::class,
ProcessedMetric::class,
], true);
self::assertTrue($unserialized !== false, "Unknown serialization error.");
} catch (\Exception $ex) {
$this->comparisonFailures[] = new \Exception("Processed response in '$processedFilePath' could not be unserialized: " . $ex->getMessage());
}
return;
}
$_GET = $originalGET;
try {
$expectedResponse = Response::loadFromFile($expectedFilePath, $params, $requestUrl);
} catch (Exception $ex) {
$this->handleMissingExpectedFile($expectedFilePath, $processedResponse);
return;
}
try {
$errorMessage = get_class($this) . ": Differences with expected in '$processedFilePath'";
Response::assertEquals($expectedResponse, $processedResponse, $errorMessage);
} catch (Exception $ex) {
$this->comparisonFailures[] = $ex;
}
}
private function handleMissingExpectedFile($expectedFilePath, Response $processedResponse)
{
$this->missingExpectedFiles[] = $expectedFilePath;
print("The expected file is not found at '$expectedFilePath'. The Processed response was:");
print("\n----------------------------\n\n");
var_dump($processedResponse->getResponseText());
print("\n----------------------------\n");
}
public static function assertApiResponseHasNoError($response)
{
if(!is_string($response)) {
$response = json_encode($response);
}
self::assertTrue(stripos($response, 'error') === false, "error in $response");
self::assertTrue(stripos($response, 'exception') === false, "exception in $response");
}
protected static function getProcessedAndExpectedDirs()
{
$path = static::getPathToTestDirectory();
$processedPath = $path . '/processed/';
if (!is_dir($processedPath)) {
mkdir($processedPath, $mode = 0777, $recursive = true);
}
if (!is_writable($processedPath)) {
self::fail('To run the tests, you need to give write permissions to the following directory (create it if '
. 'it doesn\'t exist).
mkdir ' . $processedPath . '
chmod 777 ' . $processedPath
. '
');
}
return array($processedPath, $path . '/expected/');
}
protected function getProcessedAndExpectedPaths($testName, $testId, $format = null, $compareAgainst = false)
{
$filenameSuffix = '__' . $testId;
if ($format) {
$filenameSuffix .= ".$format";
}
$processedFilename = $testName . $filenameSuffix;
$expectedFilename = $compareAgainst ? ('test_' . $compareAgainst) : $testName;
$expectedFilename .= $filenameSuffix;
list($processedDir, $expectedDir) = static::getProcessedAndExpectedDirs();
return array($processedDir . $processedFilename, $expectedDir . $expectedFilename);
}
/**
* Returns an array describing the API methods to call & compare with
* expected output.
*
* The returned array must be of the following format:
*
* array(
* array('SomeAPI.method', array('testOption1' => 'value1', 'testOption2' => 'value2'),
* array(array('SomeAPI.method', 'SomeOtherAPI.method'), array(...)),
* .
* .
* .
* )
*
*
* Valid test options are described in the ApiTestConfig class docs.
*
* All test options are optional, except 'idSite' & 'date'.
*/
public function getApiForTesting()
{
return array();
}
/**
* Gets the string prefix used in the name of the expected/processed output files.
*/
public static function getOutputPrefix()
{
$parts = explode("\\", get_called_class());
$result = end($parts);
$result = str_replace('Test_Piwik_Integration_', '', $result);
return $result;
}
/**
* Assert that the response of an API method call is the same as the contents in an
* expected file.
*
* @param string $api ie, `"DevicesDetection.getBrowsers"`
* @param array $queryParams Query parameters to send to the API.
*/
public function assertApiResponseEqualsExpected($apiMethod, $queryParams)
{
$this->runApiTests($apiMethod, array(
'idSite' => $queryParams['idSite'],
'date' => $queryParams['date'],
'periods' => $queryParams['period'],
'format' => isset($queryParams['format']) ? $queryParams['format'] : 'xml',
'testSuffix' => '_' . $this->getName(), // TODO: instead of using a test suffix, the whole file name should just be the test method
'otherRequestParameters' => $queryParams
));
}
/**
* Runs API tests.
*/
protected function runApiTests($api, $params)
{
$testConfig = new ApiTestConfig($params);
$testName = 'test_' . static::getOutputPrefix();
$this->missingExpectedFiles = array();
$this->comparisonFailures = array();
if ($testConfig->disableArchiving) {
Rules::$archivingDisabledByTests = true;
Config::getInstance()->General['browser_archiving_disabled_enforce'] = 1;
} else {
Rules::$archivingDisabledByTests = false;
Config::getInstance()->General['browser_archiving_disabled_enforce'] = 0;
}
if ($testConfig->language) {
$this->changeLanguage($testConfig->language);
}
$testRequests = $this->getTestRequestsCollection($api, $testConfig, $api);
foreach ($testRequests->getRequestUrls() as $apiId => $requestUrl) {
$this->_testApiUrl($testName . $testConfig->testSuffix, $apiId, $requestUrl, $testConfig->compareAgainst, $params);
}
// change the language back to en
if ($this->lastLanguage != 'en') {
$this->changeLanguage('en');
}
$this->printApiTestFailures();
return count($this->comparisonFailures) == 0;
}
private function printApiTestFailures()
{
if (!empty($this->missingExpectedFiles)) {
$expectedDir = dirname(reset($this->missingExpectedFiles));
$this->fail(" ERROR: Could not find expected API output '"
. implode("', '", $this->missingExpectedFiles)
. "'. For new tests, to pass the test, you can copy files from the processed/ directory into"
. " $expectedDir after checking that the output is valid. %s ");
}
// Display as one error all sub-failures
if (!empty($this->comparisonFailures)) {
$this->printComparisonFailures();
throw reset($this->comparisonFailures);
}
}
protected function getTestRequestsCollection($api, $testConfig, $apiToCall)
{
return new Collection($api, $testConfig, $apiToCall);
}
private function printComparisonFailures()
{
$messages = '';
foreach ($this->comparisonFailures as $index => $failure) {
$msg = $failure->getMessage();
$msg = strtok($msg, "\n");
$messages .= "\n#" . ($index + 1) . ": " . $msg;
}
$messages .= " \n ";
print($messages);
}
/**
* changing the language within one request is a bit fancy
* in order to keep the core clean, we need a little hack here
*
* @param string $langId
*/
protected function changeLanguage($langId)
{
if ($this->lastLanguage != $langId) {
$_GET['language'] = $langId;
/** @var Translator $translator */
$translator = StaticContainer::get('Piwik\Translation\Translator');
$translator->setCurrentLanguage($langId);
}
$this->lastLanguage = $langId;
}
/**
* Path where expected/processed output files are stored.
*/
public static function getPathToTestDirectory()
{
$up = DIRECTORY_SEPARATOR . '..';
return dirname(__FILE__) . $up . $up . DIRECTORY_SEPARATOR . 'System';
}
/**
* Returns an array associating table names w/ lists of row data.
*
* @return array
*/
protected static function getDbTablesWithData()
{
$result = array();
$tables = Db::fetchAll('SHOW TABLES'); // tests should be in a clean database, so we can just get all tables
if (!empty($tables)) {
$tables = array_column($tables, key($tables[0]));
}
foreach ($tables as $tableName) {
$result[$tableName] = Db::fetchAll("SELECT * FROM `$tableName`");
}
return $result;
}
/**
* Truncates all tables then inserts the data in $tables into each
* mapped table.
*
* @param array $tables Array mapping table names with arrays of row data.
*/
protected static function restoreDbTables($tables)
{
$db = Db::fetchOne("SELECT DATABASE()");
if (empty($db)) {
Db::exec("USE " . Config::getInstance()->database_tests['dbname']);
}
DbHelper::truncateAllTables();
// insert data
$archiveTables = Db::fetchAll("SHOW TABLES LIKE '%archive_%'");
if (!empty($archiveTables)) {
$archiveTables = array_column($archiveTables, key($archiveTables[0]));
}
foreach ($tables as $table => $rows) {
// create table if it's an archive table
if (strpos($table, 'archive_') !== false && !in_array($table, $archiveTables)) {
$tableType = strpos($table, 'archive_numeric') !== false ? 'archive_numeric' : 'archive_blob';
$createSql = DbHelper::getTableCreateSql($tableType);
$createSql = str_replace(Common::prefixTable($tableType), $table, $createSql);
Db::query($createSql);
}
if (empty($rows)) {
continue;
}
foreach ($rows as $row) {
$rowsSql = array();
$bind = array();
$values = array();
foreach ($row as $value) {
if (is_null($value)) {
$values[] = 'NULL';
} else {
// is_numeric cannot be used here since some strings will look like floating point numbers (eg 3e456)
$isNumeric = preg_match('/^\d+(\.\d+)?$/', $value);
if ($isNumeric) {
$values[] = $value;
} else if (!ctype_print($value)) {
$values[] = "x'" . bin2hex($value) . "'";
} else {
$values[] = "?";
$bind[] = $value;
}
}
}
$rowsSql[] = "(" . implode(',', $values) . ")";
$sql = "INSERT INTO `$table` VALUES " . implode(',', $rowsSql);
try {
Db::query($sql, $bind);
} catch( Exception $e) {
throw new Exception("error while inserting $sql into $table the data. SQl data: " . var_export($sql, true) . ", Bind array: " . var_export($bind, true) . ". Erorr was -> " . $e->getMessage());
}
}
}
}
/**
* Drops all archive tables.
*/
public static function deleteArchiveTables()
{
DbHelper::deleteArchiveTables();
}
public function assertNotDbConnectionCreated($message = 'A database connection was created but should not.')
{
self::assertFalse(Db::hasDatabaseObject(), $message);
self::assertFalse(Db::hasReaderDatabaseObject(), $message);
}
public function assertDbConnectionCreated($message = 'A database connection was not created but should.')
{
self::assertTrue(Db::hasDatabaseObject(), $message);
}
/**
* Use this method to return custom container configuration that you want to apply for the tests.
* This configuration will override Fixture config.
*
* @return array
*/
public static function provideContainerConfigBeforeClass()
{
return array();
}
public function hasDependencies(): bool
{
if (method_exists($this, 'requires')) {
return count($this->requires()) > 0;
}
return parent::hasDependencies();
}
}
SystemTestCase::$fixture = new \Piwik\Tests\Framework\Fixture();