mautic / app /bundles /CoreBundle /Test /MauticMysqlTestCase.php
chrisbryan17's picture
Upload folder using huggingface_hub
d2897cd verified
raw
history blame contribute delete
13.9 kB
<?php
namespace Mautic\CoreBundle\Test;
use Doctrine\DBAL\Exception as DBALException;
use Mautic\InstallBundle\InstallFixtures\ORM\LeadFieldData;
use Mautic\InstallBundle\InstallFixtures\ORM\RoleData;
use Mautic\UserBundle\DataFixtures\ORM\LoadRoleData;
use Mautic\UserBundle\DataFixtures\ORM\LoadUserData;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Component\Process\Process;
abstract class MauticMysqlTestCase extends AbstractMauticTestCase
{
private bool $databaseInstalled = false;
private bool $setUpInvoked = false;
/**
* Use transaction rollback for cleanup. Sometimes it is not possible to use it because of the following:
* 1. A query that alters a DB schema causes an open transaction being committed immediately.
* 2. Full-text search does not see uncommitted changes.
*
* @var bool
*/
protected $useCleanupRollback = true;
/**
* @param array<mixed> $data
*/
public function __construct(?string $name = null, array $data = [], $dataName = '')
{
parent::__construct($name, $data, $dataName);
$this->configParams += [
'db_driver' => 'pdo_mysql',
];
}
/**
* @throws \Exception
*/
protected function setUp(): void
{
$this->setUpInvoked = true;
parent::setUp();
$this->backupLocalConfig();
if (!$this->isDatabasePrepared()) {
$this->prepareDatabase();
if ($this->databaseInstalled) {
// re-create client/container as some services can be already wired
parent::setUpSymfony($this->configParams);
}
$this->markDatabasePrepared();
}
if ($this->useCleanupRollback) {
$this->beforeBeginTransaction();
$this->connection->beginTransaction();
}
}
/**
* @see beforeTearDown()
*/
final protected function tearDown(): void
{
$this->restoreLocalConfig();
$customFieldsReset = $this->resetCustomFields();
$this->beforeTearDown();
if (!$this->setUpInvoked) {
throw new \LogicException('You omitted invoking parent::setUp(). This may lead to side effects.');
}
$isTransactionActive = $this->connection->isTransactionActive();
if ($isTransactionActive && $this->useCleanupRollback) {
$this->insertRollbackCheckData();
$this->connection->rollback();
}
if (!$this->useCleanupRollback || !$isTransactionActive || $customFieldsReset || !$this->wasRollbackSuccessful()) {
$this->resetDatabase();
}
$this->restoreShellVerbosity();
$this->clearCache();
parent::tearDown();
}
/**
* Override this method to execute some logic right before the transaction begins.
*/
protected function beforeBeginTransaction(): void
{
}
/**
* Override this method to execute some logic right before the tearDown() is invoked.
*/
protected function beforeTearDown(): void
{
}
protected function setUpSymfony(array $defaultConfigOptions = []): void
{
if ($this->useCleanupRollback && isset($this->client)) {
throw new \LogicException('You cannot re-create the client when a transaction rollback for cleanup is enabled. Turn it off using $useCleanupRollback property or avoid re-creating a client.');
}
self::ensureKernelShutdown();
parent::setUpSymfony($defaultConfigOptions);
}
/**
* Helper method that eases resetting auto increment values for passed $tables.
* You should avoid using this method as relying on fixed auto-increment values makes tests more fragile.
* For example, you should never assume that IDs of first three records are always 1, 2 and 3.
*
* @throws DBALException
*/
protected function resetAutoincrement(array $tables): void
{
$prefix = $this->getTablePrefix();
$connection = $this->connection;
foreach ($tables as $table) {
$connection->executeQuery(sprintf('ALTER TABLE `%s%s` AUTO_INCREMENT=1', $prefix, $table));
}
}
protected function createAnotherClient(string $username = 'admin', string $password = 'mautic'): KernelBrowser
{
// turn off rollback cleanup as this client creates a separate DB connection
$this->useCleanupRollback = false;
return self::createClient(
$this->clientOptions,
[
'PHP_AUTH_USER' => $username,
'PHP_AUTH_PW' => $password,
]
);
}
/**
* Warning: To perform Truncate on tables with foreign keys we have to turn off the foreign keys temporarily.
* This may lead to corrupted data. Make sure you know what you are doing.
*
* @throws DBALException
*/
protected function truncateTables(string ...$tables): void
{
$prefix = MAUTIC_TABLE_PREFIX;
foreach ($tables as $table) {
$this->connection->executeQuery("SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE `{$prefix}{$table}`; SET FOREIGN_KEY_CHECKS = 1;");
}
}
/**
* @throws \Exception
*/
private function applySqlFromFile($file): void
{
$connection = $this->connection;
$command = 'mysql -h"${:db_host}" -P"${:db_port}" -u"${:db_user}" "${:db_name}" < "${:db_backup_file}"';
$envVars = [
'MYSQL_PWD' => $this->connection->getParams()['password'],
'db_host' => $this->connection->getParams()['host'],
'db_port' => $this->connection->getParams()['port'],
'db_user' => $this->connection->getParams()['user'],
'db_name' => $this->connection->getParams()['dbname'],
'db_backup_file' => $file,
];
$process = Process::fromShellCommandline($command);
$process->run(null, $envVars);
// executes after the command finishes
if (!$process->isSuccessful()) {
throw new \Exception($command.' failed with status code '.$process->getExitCode().' and last line of "'.$process->getErrorOutput().'"');
}
}
/**
* Reset each test using a SQL file if possible to prevent from having to run the fixtures over and over.
*
* @throws \Exception
*/
private function prepareDatabase(): void
{
if (!function_exists('proc_open')) {
$this->installDatabase();
return;
}
$sqlDumpFile = $this->getSqlFilePath('fresh_db');
if (!file_exists($sqlDumpFile)) {
$this->installDatabase();
$this->dumpToFile($sqlDumpFile);
$this->generateResetDatabaseSql($this->getSqlFilePath('reset_db'));
return;
}
$this->applySqlFromFile($sqlDumpFile);
}
private function resetDatabase(): void
{
$this->applySqlFromFile($this->getSqlFilePath('reset_db'));
}
/**
* @throws \Exception
*/
private function installDatabase(): void
{
$this->createDatabase();
$this->applyMigrations();
$this->installDatabaseFixtures([LeadFieldData::class, RoleData::class, LoadRoleData::class, LoadUserData::class]);
$this->databaseInstalled = true;
}
private function createDatabase(): void
{
$this->testSymfonyCommand('doctrine:database:drop', ['--if-exists' => true, '--force' => true]);
$this->testSymfonyCommand('doctrine:database:create');
$this->testSymfonyCommand('doctrine:schema:create');
$this->testSymfonyCommand('doctrine:migration:sync-metadata-storage');
}
private function generateResetDatabaseSql(string $file): void
{
$content = 'SET autocommit=0;'.PHP_EOL;
$content .= 'SET unique_checks=0;'.PHP_EOL;
$content .= 'SET FOREIGN_KEY_CHECKS=0;'.PHP_EOL;
$tables = $this->connection->executeQuery('SELECT TABLE_NAME FROM information_schema.tables WHERE table_type = "BASE TABLE" AND table_schema = ?', [$this->connection->getParams()['dbname']])
->fetchFirstColumn();
foreach ($tables as $table) {
$content .= sprintf('DELETE FROM %s;'.PHP_EOL, $table);
}
$password = ($this->connection->getParams()['password']) ? " -p{$this->connection->getParams()['password']}" : '';
$command = "mysqldump --skip-triggers --compact --no-create-info --skip-opt --single-transaction --opt -h{$this->connection->getParams()['host']} -P{$this->connection->getParams()['port']} -u{$this->connection->getParams()['user']}$password {$this->connection->getParams()['dbname']} | grep -v \"LOCK TABLE\" | grep -v \"ALTER TABLE\"";
$content .= shell_exec($command);
$content .= 'COMMIT;'.PHP_EOL;
$content .= 'SET unique_checks=1;'.PHP_EOL;
$content .= 'SET FOREIGN_KEY_CHECKS=1;'.PHP_EOL;
file_put_contents($file, $content);
}
/**
* @throws \Exception
*/
private function dumpToFile(string $sqlDumpFile): void
{
$connection = $this->connection;
$command = 'mysqldump --opt -h"${:db_host}" -P"${:db_port}" -u"${:db_user}" "${:db_name}" > "${:db_backup_file}"';
$envVars = [
'MYSQL_PWD' => $this->connection->getParams()['password'],
'db_host' => $this->connection->getParams()['host'],
'db_port' => $this->connection->getParams()['port'],
'db_user' => $this->connection->getParams()['user'],
'db_name' => $this->connection->getParams()['dbname'],
'db_backup_file' => $sqlDumpFile,
];
$process = Process::fromShellCommandline($command);
$process->run(null, $envVars);
// executes after the command finishes
if (!$process->isSuccessful()) {
if (file_exists($sqlDumpFile)) {
unlink($sqlDumpFile);
}
throw new \Exception($command.' failed with status code '.$process->getExitCode().' and last line of "'.$process->getErrorOutput().'"');
}
}
/**
* Restores the shell verbosity that might be set by Symfony console globally.
*
* @see \Symfony\Component\Console\Application::configureIO()
*/
private function restoreShellVerbosity(): void
{
$defaultVerbosity = 0;
putenv('SHELL_VERBOSITY='.$defaultVerbosity);
$_ENV['SHELL_VERBOSITY'] = $defaultVerbosity;
$_SERVER['SHELL_VERBOSITY'] = $defaultVerbosity;
}
private function getSqlFilePath(string $name): string
{
return sprintf('%s/%s-%s.sql', static::getContainer()->getParameter('kernel.cache_dir'), $name, $this->connection->getParams()['dbname']);
}
private function resetCustomFields(): bool
{
$prefix = $this->getTablePrefix();
$result = $this->connection->fetchAllAssociative(sprintf('SELECT alias, object FROM %slead_fields WHERE date_added IS NOT NULL', $prefix));
foreach ($result as $data) {
$table = 'company' === $data['object'] ? 'companies' : 'leads';
try {
$this->connection->executeStatement(sprintf('ALTER TABLE %s%s DROP COLUMN %s', $prefix, $table, $data['alias']));
} catch (\Exception) {
}
}
return (bool) $result;
}
private function backupLocalConfig(): void
{
$path = $this->getLocalConfigFile();
if (!file_exists($path)) {
file_put_contents($path, '<?php $parameters = [];');
}
if (!copy($path, $path.'.backup')) {
throw new \RuntimeException(sprintf('Unable to copy file %s => %s', $path, $path.'.backup'));
}
}
private function restoreLocalConfig(): void
{
$path = $this->getLocalConfigFile();
if (!rename($path.'.backup', $path)) {
throw new \RuntimeException(sprintf('Unable to move file %s => %s', $path.'.backup', $path));
}
}
private function getLocalConfigFile(): string
{
/** @var \AppKernel $kernel */
$kernel = static::$kernel;
return $kernel->getLocalConfigFile();
}
private function insertRollbackCheckData(): void
{
$this->connection->executeStatement("INSERT INTO {$this->getTablePrefix()}ip_addresses (ip_address) VALUES ('127.0.0.1')");
}
private function wasRollbackSuccessful(): bool
{
return false === $this->connection->fetchOne("SELECT 1 FROM {$this->getTablePrefix()}ip_addresses LIMIT 1");
}
private function getTablePrefix(): string
{
return (string) static::getContainer()->getParameter('mautic.db_table_prefix');
}
private function isDatabasePrepared(): bool
{
return file_exists($this->getSqlFilePath('prepared'));
}
private function markDatabasePrepared(): void
{
touch($this->getSqlFilePath('prepared'));
}
private function clearCache(): void
{
$cacheProvider = static::getContainer()->get('mautic.cache.provider');
\assert($cacheProvider instanceof CacheItemPoolInterface);
$cacheProvider->clear();
}
/**
* Helper method to ensure booleans are strings in HTTP payloads.
*
* this ensures the payload is compatible with a change in Symfony 5.2
*
* @see https://github.com/symfony/browser-kit/commit/1d033e7dccc9978dd7a2bde778d06ebbbf196392
*/
protected function generateTypeSafePayload(mixed $payload): mixed
{
array_walk_recursive($payload, function (&$value): void {
$value = is_bool($value) ? ($value ? '1' : '0') : $value;
});
return $payload;
}
}