Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\CleanupExportedFilesCommand;
use Mautic\LeadBundle\Command\ContactScheduledExportCommand;
use Mautic\LeadBundle\Entity\ContactExportScheduler;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
final class CleanupExportedFilesCommandFunctionalTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->configParams['clear_export_files_after_days'] = 0;
$this->configParams['contact_export_dir'] = '/tmp';
parent::setUp();
}
/**
* @throws \Exception
*/
public function testCleanupContactExportFiles(): void
{
$filePath = $this->exportContactToCsvFile();
$this->testSymfonyCommand(CleanupExportedFilesCommand::COMMAND_NAME);
Assert::assertFileDoesNotExist($filePath);
}
private function exportContactToCsvFile(): string
{
$this->createContacts();
$this->client->request(
Request::METHOD_POST,
's/contacts/batchExport',
['filetype' => 'csv']
);
Assert::assertTrue($this->client->getResponse()->isOk());
$contactExportSchedulerRows = $this->checkContactExportScheduler(1);
/** @var ContactExportScheduler $contactExportScheduler */
$contactExportScheduler = $contactExportSchedulerRows[0];
$this->testSymfonyCommand(ContactScheduledExportCommand::COMMAND_NAME, ['--ids' => $contactExportScheduler->getId()]);
/** @var CoreParametersHelper $coreParametersHelper */
$coreParametersHelper = self::getContainer()->get('mautic.helper.core_parameters');
$zipFileName = 'contacts_export_'.$contactExportScheduler->getScheduledDateTime()
->format('Y_m_d_H_i_s').'.zip';
$filePath = $coreParametersHelper->get('contact_export_dir').'/'.$zipFileName;
Assert::assertFileExists($filePath);
return $filePath;
}
private function createContacts(): void
{
$contacts = [];
for ($i = 1; $i <= 2; ++$i) {
$contact = new Lead();
$contact
->setFirstname('ContactFirst'.$i)
->setLastname('ContactLast'.$i)
->setEmail('FirstLast'.$i.'@email.com');
$contacts[] = $contact;
}
$leadModel = self::getContainer()->get('mautic.lead.model.lead');
$leadModel->saveEntities($contacts);
}
/**
* @return array<mixed>
*/
private function checkContactExportScheduler(int $count): array
{
$repo = $this->em->getRepository(ContactExportScheduler::class);
$allRows = $repo->findAll();
Assert::assertCount($count, $allRows);
return $allRows;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\ExitCode;
use Mautic\CoreBundle\ProcessSignal\Exception\SignalCaughtException;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Mautic\CoreBundle\Twig\Helper\DateHelper;
use Mautic\CoreBundle\Twig\Helper\FormatterHelper;
use Mautic\LeadBundle\Command\ContactScheduledExportCommand;
use Mautic\LeadBundle\Entity\ContactExportScheduler;
use Mautic\LeadBundle\Entity\ContactExportSchedulerRepository;
use Mautic\LeadBundle\Model\ContactExportSchedulerModel;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ContactScheduledExportCommandTest extends TestCase
{
public function testForSignalCaughtException(): void
{
$contactExportScheduledModel = $this->createMock(ContactExportSchedulerModel::class);
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(TranslatorInterface::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$dateHelper = new DateHelper(
'F j, Y g:i a T',
'D, M d',
'F j, Y',
'g:i a',
$translator,
$coreParametersHelper
);
$formatterHelper = new FormatterHelper($dateHelper, $translator);
$processSignalService = $this->createMock(ProcessSignalService::class);
$contactExportSchedulerRepository = $this->createMock(ContactExportSchedulerRepository::class);
$contactExportSchedulerRepository->method('findBy')
->willReturn([new ContactExportScheduler()]);
$contactExportScheduledModel->method('getRepository')
->willReturn($contactExportSchedulerRepository);
$eventDispatcher->expects($this->once())
->method('dispatch')
->willThrowException(new SignalCaughtException(1));
$command = new class($contactExportScheduledModel, $eventDispatcher, $formatterHelper, $processSignalService) extends ContactScheduledExportCommand {
public function getExecute(InputInterface $input, OutputInterface $output): int
{
return $this->execute($input, $output);
}
};
$inputInterfaceMock = $this->createMock(InputInterface::class);
$outputInterfaceMock = $this->createMock(OutputInterface::class);
$inputInterfaceMock->method('getOption')
->with('ids')
->willReturn(1);
$this->assertSame(ExitCode::TERMINATED, $command->getExecute($inputInterfaceMock, $outputInterfaceMock));
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Field\BackgroundService;
use Mautic\LeadBundle\Field\Command\CreateCustomFieldCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Contracts\Translation\TranslatorInterface;
class CreateCustomFieldCommandTest extends TestCase
{
private BackgroundService $backgroundServiceMock;
private TranslatorInterface $translatorMock;
private LeadFieldRepository $leadFieldRepositoryMock;
private PathsHelper $pathsHelperMock;
private CoreParametersHelper $coreParametersHelper;
protected function setUp(): void
{
$this->backgroundServiceMock = $this->createMock(BackgroundService::class);
$this->translatorMock = $this->createMock(TranslatorInterface::class);
$this->leadFieldRepositoryMock = $this->createMock(LeadFieldRepository::class);
$this->pathsHelperMock = $this->createMock(PathsHelper::class);
$this->coreParametersHelper = $this->createMock(CoreParametersHelper::class);
}
#[\PHPUnit\Framework\Attributes\DataProvider('completeRunMethodProvider')]
public function testCompleteRunMethodIsCalled(bool $checkRunStatusResult, int $completeRunExpected): void
{
$command = $this->getMockBuilder(CreateCustomFieldCommand::class)
->setConstructorArgs([
$this->backgroundServiceMock,
$this->translatorMock,
$this->leadFieldRepositoryMock,
$this->pathsHelperMock,
$this->coreParametersHelper,
])
->onlyMethods(['completeRun', 'checkRunStatus'])
->getMock();
$command->expects($this->once())->method('checkRunStatus')->willReturn($checkRunStatusResult);
$command->expects($this->exactly($completeRunExpected))->method('completeRun');
$input = new ArrayInput([
'--id' => '123',
]);
$output = new BufferedOutput();
$command->run($input, $output);
}
/**
* @return array<int, array<int, bool|int>>
*/
public static function completeRunMethodProvider(): array
{
return [
[true, 1], // `completeRun` should be called once
[false, 0], // `completeRun` should never be called
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\DeduplicateCommand;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadField;
use PHPUnit\Framework\Assert;
final class DeduplicateCommandFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
protected function setUp(): void
{
if ('testDeduplicateCommandWithAnotherUniqueFieldAndAnd' === $this->name()) {
$this->configParams['contact_unique_identifiers_operator'] = CompositeExpression::TYPE_AND;
}
parent::setUp();
}
public function testDeduplicateCommandWithUniqueEmail(): void
{
$contactRepository = $this->em->getRepository(Lead::class);
$contactDeduper = static::getContainer()->get('mautic.lead.deduper');
Assert::assertSame(0, $contactRepository->count([]), 'Some contacts were forgotten to remove from other tests');
$this->saveContact('john@doe.email'); // 1
$this->saveContact('john@doe.email'); // 1
$this->saveContact('john@doe.email'); // 1
$this->saveContact('john@doe.email'); // 1
$this->saveContact('anna@munic.email'); // 2
$this->saveContact('anna@munic.email'); // 2
$this->saveContact('jane@gabriel.email'); // 3
$this->em->flush();
Assert::assertSame(7, $contactRepository->count([]));
Assert::assertSame(
2,
$contactDeduper->countDuplicatedContacts(array_keys($contactDeduper->getUniqueFields('lead'))),
'The deduper should see and process only 2 duplicated contacts. The third is unique.'
);
$output = $this->testSymfonyCommand(DeduplicateCommand::NAME);
Assert::assertSame(3, $contactRepository->count([]), $output->getDisplay());
}
public function testDeduplicateCommandWithAnotherUniqueFieldAndAnd(): void
{
$contactRepository = $this->em->getRepository(Lead::class);
$fieldRepository = $this->em->getRepository(LeadField::class);
Assert::assertSame(0, $contactRepository->count([]), 'Some contacts were forgotten to remove from other tests');
$this->saveContact('john@doe.email', '111111111'); // 1
$this->saveContact('john@doe.email', '111111111'); // 1
$this->saveContact('john@doe.email', '222222222'); // 2
$this->saveContact('john@doe.email', '222222222'); // 2
$this->saveContact('anna@munic.email', '333333333'); // 3
$this->saveContact('anna@munic.email', '333333333'); // 3
$this->saveContact('jane@gabriel.email', '4444444444'); // 4
$this->saveContact('jane.gabriel@gmail.com', '4444444444'); // 5
$phoneField = $fieldRepository->findOneBy(['alias' => 'phone']);
\assert($phoneField instanceof LeadField);
$phoneField->setIsUniqueIdentifer(true);
$phoneField->setLabel('Cell phone'); // Testing also field with more words.
$this->em->persist($phoneField);
$this->em->flush();
Assert::assertSame(8, $contactRepository->count([]));
$output = $this->testSymfonyCommand(DeduplicateCommand::NAME);
Assert::assertSame(5, $contactRepository->count([]), $output->getDisplay());
}
public function testDeduplicateCommandWithAnotherUniqueFieldAndOr(): void
{
$contactRepository = $this->em->getRepository(Lead::class);
$fieldRepository = $this->em->getRepository(LeadField::class);
Assert::assertSame(0, $contactRepository->count([]), 'Some contacts were forgotten to remove from other tests');
$this->saveContact('john@doe.email', '111111111'); // 1
$this->saveContact('john@doe.email', '111111111'); // 1
$this->saveContact('john@doe.email', '222222222'); // 1
$this->saveContact('john@doe.email', '222222222'); // 1
$this->saveContact('anna@munic.email', '333333333'); // 2
$this->saveContact('anna@munic.email', '333333333'); // 2
$this->saveContact('jane@gabriel.email', '4444444444'); // 3
$this->saveContact('jane.gabriel@gmail.com', '4444444444'); // 3
$phoneField = $fieldRepository->findOneBy(['alias' => 'phone']);
\assert($phoneField instanceof LeadField);
$phoneField->setIsUniqueIdentifer(true);
$phoneField->setLabel('Cell phone'); // Testing also field with more words.
$this->em->persist($phoneField);
$this->em->flush();
Assert::assertSame(8, $contactRepository->count([]));
$output = $this->testSymfonyCommand(DeduplicateCommand::NAME);
Assert::assertSame(3, $contactRepository->count([]), $output->getDisplay());
}
private function saveContact(string $email, ?string $phone = null): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$contact->setPhone($phone);
$contact->setDateIdentified(new \DateTime());
$this->em->persist($contact);
return $contact;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\DeduplicateIdsCommand;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
final class DeduplicateIdsCommandFunctionalTest extends MauticMysqlTestCase
{
public function testDeduplicateCommandWithContactIdsParam(): void
{
$contactRepository = $this->em->getRepository(Lead::class);
Assert::assertSame(0, $contactRepository->count([]), 'Some contacts were forgotten to remove from other tests');
$contact1 = $this->saveContact('john@doe.email');
$this->saveContact('john@doe.email');
$contact2 = $this->saveContact('jane@doe.email');
$this->saveContact('jane@doe.email');
$contact3 = $this->saveContact('anna@munic.email');
$this->saveContact('anna@munic.email');
$this->em->flush();
Assert::assertSame(6, $contactRepository->count([]));
$this->testSymfonyCommand(DeduplicateIdsCommand::NAME, ['--contact-ids' => "{$contact1->getId()},{$contact2->getId()},{$contact3->getId()}"]);
Assert::assertSame(3, $contactRepository->count([]));
}
private function saveContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$contact->setDateIdentified(new \DateTime());
$this->em->persist($contact);
return $contact;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\DeleteContactSecondaryCompaniesCommand;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\CompanyLead;
use Mautic\LeadBundle\Entity\CompanyLeadRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
final class DeleteContactSecondaryCompaniesCommandTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testDeleteContactSecondaryCompanies(): void
{
$contact = $this->getContactWithCompanies();
/** @var CompanyLeadRepository $companyLeadRepo */
$companyLeadRepo = $this->em->getRepository(CompanyLead::class);
$contactCompanies = $companyLeadRepo->getCompaniesByLeadId($contact->getId());
self::assertEquals(2, count($contactCompanies));
$this->testSymfonyCommand(DeleteContactSecondaryCompaniesCommand::NAME);
$contactCompanies = $companyLeadRepo->getCompaniesByLeadId($contact->getId());
self::assertEquals(2, count($contactCompanies));
$this->setUpSymfony(['contact_allow_multiple_companies' => 0]);
$this->testSymfonyCommand(DeleteContactSecondaryCompaniesCommand::NAME);
$contactCompanies = $companyLeadRepo->getCompaniesByLeadId($contact->getId());
self::assertEquals(1, count($contactCompanies));
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function getContactWithCompanies(): Lead
{
$company = new Company();
$company->setName('Doe Corp');
$this->em->persist($company);
$company2 = new Company();
$company2->setName('Doe Corp 2');
$this->em->persist($company2);
$contact = new Lead();
$contact->setEmail('test@test.com');
$this->em->persist($contact);
$this->em->flush();
/** @var LeadModel $leadModel */
$leadModel = self::getContainer()->get('mautic.lead.model.lead');
$this->assertTrue($leadModel->addToCompany($contact, $company));
$this->assertTrue($leadModel->addToCompany($contact, $company2));
return $contact;
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Mautic\LeadBundle\Command\ImportCommand;
use Mautic\LeadBundle\Entity\Import;
use Mautic\LeadBundle\Model\ImportModel;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use Mautic\UserBundle\Security\UserTokenSetter;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Contracts\Translation\TranslatorInterface;
class ImportCommandTest extends TestCase
{
public function testExecuteFailsIfModifiedByIsNotSet(): void
{
$translatorMock = $this->createMock(TranslatorInterface::class);
$translatorMock->method('trans')->willReturnCallback(fn ($id) => $id);
$importMock = $this->createMock(Import::class);
$importModelMock = $this->createMock(ImportModel::class);
$loggerMock = $this->createMock(Logger::class);
$notificationMock = $this->createMock(NotificationModel::class);
$userModelMock = $this->createMock(UserModel::class);
$tokenStorageMock = $this->createMock(TokenStorage::class);
$userTokenSetter = new UserTokenSetter($userModelMock, $tokenStorageMock);
$importModelMock->expects($this->once())
->method('getImportToProcess')
->willReturn($importMock);
$importCommand = new class($translatorMock, $importModelMock, new ProcessSignalService(), $userTokenSetter, $loggerMock, $notificationMock) extends ImportCommand {
public function getExecute(InputInterface $input, OutputInterface $output): int
{
return $this->execute($input, $output);
}
};
$inputInterfaceMock = $this->createMock(InputInterface::class);
$outputInterfaceMock = $this->createMock(OutputInterface::class);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Import does not have "modifiedBy" property set.');
$importCommand->getExecute($inputInterfaceMock, $outputInterfaceMock);
}
public function testExecute(): void
{
// Translator
$translatorMock = $this->createMock(TranslatorInterface::class);
$translatorMock->method('trans')->willReturnCallback(fn ($id) => $id);
// Import entity
$importMock = $this->createMock(Import::class);
$importMock->expects($this->once())
->method('getModifiedBy')
->willReturn(42);
$importMock->method('getProcessedRows')->willReturn(1);
$importMock->method('getInsertedCount')->willReturn(1);
$importMock->method('getUpdatedCount')->willReturn(0);
$importMock->method('getIgnoredCount')->willReturn(0);
// Import Model Mock
$importModelMock = $this->createMock(ImportModel::class);
$importModelMock->expects($this->once())
->method('getEntity')
->with(42)
->willReturn($importMock);
// User Token Setter
$user = new User();
$userModelMock = $this->createMock(UserModel::class);
$userModelMock->expects($this->once())
->method('getEntity')
->with(42)
->willReturn($user);
$tokenStorageMock = $this->createMock(TokenStorage::class);
$tokenStorageMock->expects($this->once())
->method('setToken');
$userTokenSetter = new UserTokenSetter($userModelMock, $tokenStorageMock);
$loggerMock = $this->createMock(Logger::class);
$notificationMock = $this->createMock(NotificationModel::class);
// No notification expected for successful imports - they're handled in ImportModel
$importCommand = new class($translatorMock, $importModelMock, new ProcessSignalService(), $userTokenSetter, $loggerMock, $notificationMock) extends ImportCommand {
public function getExecute(InputInterface $input, OutputInterface $output): int
{
return $this->execute($input, $output);
}
};
// InputInterface
$inputInterfaceMock = $this->createMock(InputInterface::class);
$matcher = $this->exactly(2);
$inputInterfaceMock->expects($matcher)->method('getOption')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame('id', $parameters[0]);
return 42;
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame('limit', $parameters[0]);
return 10;
}
});
// OutputInterface
$outputInterfaceMock = $this->createMock(OutputInterface::class);
// Start test
$this->assertSame(0, $importCommand->getExecute($inputInterfaceMock, $outputInterfaceMock));
}
public function testExecuteAddsNotificationOnFailure(): void
{
$translatorMock = $this->createMock(TranslatorInterface::class);
$translatorMock->method('trans')->willReturnCallback(fn ($id) => $id);
$importMock = $this->createMock(Import::class);
$importMock->expects($this->once())
->method('getModifiedBy')
->willReturn(42);
$importMock->method('getStatusInfo')->willReturn('fail');
$importMock->method('getProcessedRows')->willReturn(1);
$importMock->method('getInsertedCount')->willReturn(0);
$importMock->method('getUpdatedCount')->willReturn(0);
$importMock->method('getIgnoredCount')->willReturn(1);
$importModelMock = $this->createMock(ImportModel::class);
$importModelMock->expects($this->once())
->method('getEntity')
->with(42)
->willReturn($importMock);
$importModelMock->expects($this->once())
->method('beginImport')
->willThrowException(new \Mautic\LeadBundle\Exception\ImportFailedException('fail'));
$user = new User();
$userModelMock = $this->createMock(UserModel::class);
$userModelMock->expects($this->once())
->method('getEntity')
->with(42)
->willReturn($user);
$tokenStorageMock = $this->createMock(TokenStorage::class);
$tokenStorageMock->expects($this->once())->method('setToken');
$userTokenSetter = new UserTokenSetter($userModelMock, $tokenStorageMock);
$loggerMock = $this->createMock(Logger::class);
$notificationMock = $this->createMock(NotificationModel::class);
$notificationMock->expects($this->once())->method('addNotification');
$importCommand = new class($translatorMock, $importModelMock, new ProcessSignalService(), $userTokenSetter, $loggerMock, $notificationMock) extends ImportCommand {
public function getExecute(InputInterface $input, OutputInterface $output): int
{
return $this->execute($input, $output);
}
};
$inputInterfaceMock = $this->createMock(InputInterface::class);
$inputInterfaceMock->method('getOption')->willReturnMap([
['id', 42],
['limit', 10],
]);
$outputInterfaceMock = $this->createMock(OutputInterface::class);
$this->assertSame(1, $importCommand->getExecute($inputInterfaceMock, $outputInterfaceMock));
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\SegmentCountCacheCommand;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Entity\LeadRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class SegmentCountCacheCommandFunctionalTest extends MauticMysqlTestCase
{
/**
* @throws \Exception
*/
public function testSegmentCountCacheCommand(): void
{
$contacts = $this->saveContacts();
$segment = $this->saveSegment();
$segmentId = $segment->getId();
// Run segments update command.
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentId]);
// Run segment count cache command.
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment cached contact count using the SegmentCountCacheHelper directly
$segmentCountCacheHelper = static::getContainer()->get('mautic.helper.segment.count.cache');
$count = $segmentCountCacheHelper->getSegmentContactCount($segmentId);
self::assertEquals(5, $count, "Expected segment $segmentId to have 5 contacts");
// Delete 1 contact.
$contact = $contacts[0];
$this->client->request(Request::METHOD_POST, '/s/contacts/delete/'.$contact->getId());
$clientResponse = $this->client->getResponse();
self::assertSame(Response::HTTP_OK, $clientResponse->getStatusCode());
// Run segment count cache command again.
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment cached contact count using the SegmentCountCacheHelper directly
$segmentCountCacheHelper = static::getContainer()->get('mautic.helper.segment.count.cache');
$count = $segmentCountCacheHelper->getSegmentContactCount($segmentId);
self::assertEquals(4, $count, "Expected segment $segmentId to have 4 contacts");
}
/**
* @return array<int, Lead>
*/
private function saveContacts(): array
{
// Add 5 contacts
/** @var LeadRepository $contactRepo */
$contactRepo = $this->em->getRepository(Lead::class);
$contacts = [];
for ($i = 1; $i <= 5; ++$i) {
$contact = new Lead();
$contact->setFirstname('Contact '.$i);
$contacts[] = $contact;
}
$contactRepo->saveEntities($contacts);
return $contacts;
}
private function saveSegment(): LeadList
{
// Add 1 segment
/** @var LeadListRepository $segmentRepo */
$segmentRepo = $this->em->getRepository(LeadList::class);
$segment = new LeadList();
$filters = [
[
'glue' => 'and',
'field' => 'firstname',
'object' => 'lead',
'type' => 'text',
'operator' => 'like',
'properties' => ['filter' => 'Contact'],
],
];
$segment->setName('Segment A')
->setPublicName('Segment A')
->setFilters($filters)
->setAlias('segment-a');
$segmentRepo->saveEntity($segment);
return $segment;
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Entity\ListLead;
final class SegmentFilterOnUpdateCommandFunctionalTest extends MauticMysqlTestCase
{
public function testSegmentFilterOnUpdateCommand(): void
{
$this->saveContacts();
$segmentA = $this->saveSegmentA();
$segmentAId = $segmentA->getId();
// Run segments update command.
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentAId]);
self::assertCount(5, $this->em->getRepository(ListLead::class)->findBy(['list' => $segmentAId]));
$segmentB = $this->saveSegmentB($segmentAId);
$segmentBId = $segmentB->getId();
// Run segments update command.
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentBId]);
self::assertCount(3, $this->em->getRepository(ListLead::class)->findBy(['list' => $segmentBId]));
}
/**
* @return Lead[]
*/
private function saveContacts(): array
{
// Add 10 contacts
/** @var LeadRepository $contactRepo */
$contactRepo = $this->em->getRepository(Lead::class);
$contacts = [];
for ($i = 0; $i <= 10; ++$i) {
$contact = new Lead();
$contact->setFirstname('fn'.$i);
$contact->setLastname('ln'.$i);
$contacts[] = $contact;
}
$contactRepo->saveEntities($contacts);
return $contacts;
}
private function saveSegmentA(): LeadList
{
$segment = new LeadList();
$filters = [
[
'object' => 'lead',
'glue' => 'and',
'field' => 'address1',
'type' => 'text',
'operator' => '!empty',
'properties' => ['filter' => null],
// The filter key is deprecated but sometimes it contains rubbish values including a string.
'filter' => 'somestring',
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'address1',
'type' => 'text',
'operator' => '!=',
'properties' => ['filter' => null],
// The filter key is deprecated but sometimes it contains rubbish values including an array.
'filter' => ['option A', 'option B'],
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn1'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'lastname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'ln1'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn2'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn3'],
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'lastname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'ln3'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn4'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'lastname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'ln5'],
],
];
$segment->setName('Segment A')
->setPublicName('Segment A')
->setFilters($filters)
->setAlias('segment-a');
$this->em->persist($segment);
$this->em->flush();
return $segment;
}
private function saveSegmentB(int $segmentAId): LeadList
{
$segment = new LeadList();
$filters = [
[
'object' => 'lead',
'glue' => 'and',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn6'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn2'],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'firstname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'fn3'],
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'lastname',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'ln3'],
],
[
'glue' => 'and',
'field' => 'leadlist',
'object' => 'lead',
'type' => 'leadlist',
'operator' => 'in',
'properties' => ['filter' => [$segmentAId]],
],
];
$segment->setName('Segment B')
->setPublicName('Segment B')
->setFilters($filters)
->setAlias('segment-b');
$this->em->persist($segment);
$this->em->flush();
return $segment;
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Entity\ListLead;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Segment\OperatorOptions;
use PHPUnit\Framework\Attributes\DataProvider;
class SegmentFiltersFunctionalTest extends MauticMysqlTestCase
{
private const FIELD_NAME = 'car';
protected $useCleanupRollback = false;
private string $testIdentifier;
protected function setUp(): void
{
parent::setUp();
$this->testIdentifier = 'test_'.uniqid();
}
/**
* @param array<string, mixed> $fieldDetails
* @param array<string, mixed> $segmentData
*
* @throws \Exception
*/
#[DataProvider('filtersSegmentsContacts')]
public function testFiltersHasCorrectContactsIncludedInSegment(
array $fieldDetails,
array $segmentData,
callable $checkValidContact,
): void {
$this->saveCustomField($fieldDetails);
$contacts = $this->saveContacts();
$segment = $this->saveSegment($segmentData);
$segmentId = $segment->getId();
/** @var array<int> $contactIds */
$contactIds = [];
/** @var array<int> $leadListLeadsIds */
$leadListLeadsIds = [];
// get contacts with valid filter
foreach ($contacts as $contact) {
if ($checkValidContact($contact)) {
$contactIds[] = (int) $contact->getId();
}
}
// update the segment
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentId]);
// get the lead list leads stored in db after the segment update
$leadListLeads = $this->em->getRepository(ListLead::class)->findBy(['list' => $segment]);
foreach ($leadListLeads as $listLead) {
$leadListLeadsIds[] = (int) $listLead->getLead()->getId();
}
sort($leadListLeadsIds);
sort($contactIds);
// assert filter lead ids are the same as contact ids saved in db
$this->assertSame($leadListLeadsIds, $contactIds);
}
/**
* @param array<string, mixed> $fieldDetails
*/
private function saveCustomField(array $fieldDetails = []): void
{
// Create a field and add it to the lead object.
$field = new LeadField();
$field->setLabel($fieldDetails['label']);
$field->setType($fieldDetails['type']);
$field->setObject('lead');
$field->setGroup('core');
$field->setAlias($fieldDetails['alias']);
$field->setProperties($fieldDetails['properties']);
/** @var FieldModel $fieldModel */
$fieldModel = static::getContainer()->get('mautic.lead.model.field');
$fieldModel->saveEntity($field);
}
/**
* @return array<object>
*/
private function saveContacts(): array
{
$numberOfContacts = 8;
$numberOfContactsWithBlankValue = 2;
/** @var LeadRepository $contactRepo */
$contactRepo = $this->em->getRepository(Lead::class);
$contacts = [];
$cars = [
'value1', 'value2', 'value3',
];
for ($i = 1; $i <= $numberOfContacts; ++$i) {
$contact = new Lead();
$contact->setFirstname('Contact '.$i);
$contact->setLastname($this->testIdentifier); // Use lastname to identify test contacts
if ($i > $numberOfContactsWithBlankValue) {
$contact->setFields([
'core' => [
self::FIELD_NAME => [
'value' => '',
'type' => 'multiselect',
'alias' => self::FIELD_NAME,
],
],
]);
$leadModel = static::getContainer()->get('mautic.lead.model.lead');
$leadModel->setFieldValues($contact, [self::FIELD_NAME => [$cars[$i % 3]]]);
}
$contacts[] = $contact;
}
$contactRepo->saveEntities($contacts);
return $contacts;
}
/**
* @param array<string, mixed> $segmentData
*/
private function saveSegment(array $segmentData = []): LeadList
{
/** @var LeadListRepository $segmentRepo */
$segmentRepo = $this->em->getRepository(LeadList::class);
$segment = new LeadList();
$filterToSave = $segmentData['filterToSave'];
$filters = [
[
'glue' => 'and',
'field' => $filterToSave['field'],
'object' => 'lead',
'type' => 'multiselect',
'filter' => $filterToSave['filter'],
'display' => null,
'operator' => $filterToSave['operator'],
],
[
'glue' => 'and',
'field' => 'lastname',
'object' => 'lead',
'type' => 'text',
'filter' => $this->testIdentifier,
'display' => null,
'operator' => '=',
],
];
$segment->setName($segmentData['name'])
->setFilters($filters)
->setAlias($segmentData['alias'])
->setPublicName($segmentData['name']);
$segmentRepo->saveEntity($segment);
return $segment;
}
/**
* @return iterable<int, mixed>
*/
public static function filtersSegmentsContacts(): iterable
{
$customField = [
'label' => 'Cars',
'alias' => self::FIELD_NAME,
'type' => 'multiselect',
'properties' => [
'list' => [
['label' => 'car1', 'value' => 'value1'],
['label' => 'car2', 'value' => 'value2'],
['label' => 'car3', 'value' => 'value3'],
],
],
];
$segmentData = [
'alias' => 'segment-a',
'name' => 'Segment A',
'filterToSave' => [
'field' => self::FIELD_NAME,
'filter' => [
'value1',
],
'operator' => OperatorOptions::EXCLUDING_ANY,
],
];
// to test excluding filter, should contain blank values as well
yield [
// custom field
$customField,
$segmentData,
function ($contact): bool {
return empty($contact->getFields()) || 'value1' !== $contact->getField(self::FIELD_NAME)['value'];
},
];
// to test multiple excluding values
$segmentData['filterToSave']['filter'] = ['value1', 'value2'];
yield [
// custom field
$customField,
$segmentData,
function ($contact): bool {
return
empty($contact->getFields())
|| !in_array($contact->getField(self::FIELD_NAME)['value'], ['value1', 'value2']);
},
];
// to test including filter, should NOT contain blank values
$segmentData['filterToSave']['operator'] = OperatorOptions::INCLUDING_ANY;
$segmentData['filterToSave']['filter'] = ['value1'];
yield [
// custom field
$customField,
$segmentData,
function ($contact): bool {
return !empty($contact->getFields()) && 'value1' === $contact->getField(self::FIELD_NAME)['value'];
},
];
// to test multiple including values
$segmentData['filterToSave']['filter'] = ['value1', 'value2'];
yield [
// custom field
$customField,
$segmentData,
function ($contact): bool {
return
!empty($contact->getFields())
&& in_array($contact->getField(self::FIELD_NAME)['value'], ['value1', 'value2']);
},
];
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\ListLead;
final class SegmentNumberFilterWithOrsCommandFunctionalTest extends MauticMysqlTestCase
{
public function testSegmentNuberFilterWithOrsCommand(): void
{
$contact1 = new Lead();
$contact1->setPoints(1);
$contact2 = new Lead();
$contact2->setPoints(2);
$contact3 = new Lead();
$contact3->setPoints(3);
$contact4 = new Lead();
$contact4->setPoints(4);
$this->em->persist($contact1);
$this->em->persist($contact2);
$this->em->persist($contact3);
$this->em->persist($contact4);
$segment = new LeadList();
$segment->setName('Segment A');
$segment->setPublicName('Segment A');
$segment->setAlias('segment-a');
$segment->setFilters([
[
'object' => 'lead',
'glue' => 'and',
'field' => 'points',
'type' => 'number',
'operator' => '=',
'properties' => ['filter' => 1],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'points',
'type' => 'number',
'operator' => '=',
'properties' => ['filter' => 2],
],
[
'object' => 'lead',
'glue' => 'or',
'field' => 'points',
'type' => 'number',
'operator' => '=',
'properties' => ['filter' => 3],
],
]);
$this->em->persist($segment);
$this->em->flush();
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segment->getId()]);
self::assertCount(3, $this->em->getRepository(ListLead::class)->findBy(['list' => $segment]));
}
public function testSegmentNuberFilterWithRegexCommand(): void
{
$contact1 = new Lead();
$contact1->setPoints(1);
$contact2 = new Lead();
$contact2->setPoints(2);
$contact3 = new Lead();
$contact3->setPoints(3);
$this->em->persist($contact1);
$this->em->persist($contact2);
$this->em->persist($contact3);
$segment = new LeadList();
$segment->setName('Segment A');
$segment->setPublicName('Segment A');
$segment->setAlias('segment-a');
$segment->setFilters([
[
'object' => 'lead',
'glue' => 'and',
'field' => 'points',
'type' => 'number',
'operator' => 'regexp',
'properties' => ['filter' => '^(1|3)$'],
],
]);
$this->em->persist($segment);
$this->em->flush();
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segment->getId()]);
self::assertCount(2, $this->em->getRepository(ListLead::class)->findBy(['list' => $segment]));
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadList;
final class SegmentStatCommandTest extends MauticMysqlTestCase
{
/**
* @throws \Exception
*/
public function testSegmentStatCommandWithOutSegment(): void
{
$output = $this->testSymfonyCommand('mautic:segments:stat');
$this->assertStringContainsString('There is no segment to show!!', $output->getDisplay());
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function testSegmentStatCommandWithSegment(): void
{
$segmentName = 'Segment For Campaign';
$segment = new LeadList();
$segment->setName($segmentName);
$segment->setPublicName($segmentName);
$segment->setAlias(mb_strtolower($segmentName));
$segment->setIsPublished(true);
$this->em->persist($segment);
$this->em->flush();
$campaign = new Campaign();
$campaign->setName('Campaign With LeadList');
$campaign->addList($segment);
$this->em->persist($campaign);
$this->em->flush();
$output = $this->testSymfonyCommand('mautic:segments:stat');
// test table header
$this->assertMatchesRegularExpression('/Title\s+Id\s+IsPublished\s+IsUsed/i', $output->getDisplay());
// test table content
$this->assertMatchesRegularExpression("/Segment For Campaign\s+{$segment->getId()}\s+{$segment->getIsPublished()}\s+1/i", $output->getDisplay());
}
}

View File

@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Mautic\LeadBundle\Command\UpdateLeadListsCommand;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Entity\Tag;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Segment\OperatorOptions;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Console\Command\Command;
final class UpdateLeadListCommandFunctionalTest extends MauticMysqlTestCase
{
use CreateTestEntitiesTrait;
protected $useCleanupRollback = false; // This should be here, because test is changing DDL of the leads table.
public function testFailWhenSegmentDoesNotExist(): void
{
$output = $this->testSymfonyCommand(UpdateLeadListsCommand::NAME, ['--list-id' => 999999]);
Assert::assertSame(1, $output->getStatusCode());
Assert::assertStringContainsString('Segment #999999 does not exist', $output->getDisplay());
}
#[DataProvider('provider')]
public function testCommandRebuildingAllSegments(callable $getCommandParams, callable $assert): void
{
$contact = new Lead();
$contact->setEmail('halusky@bramborak.makovec');
$segment = new LeadList();
$segment->setName('Test segment');
$segment->setPublicName('Test segment');
$segment->setAlias('test-segment');
$segment->setFilters([
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'filter' => 'halusky@bramborak.makovec',
'display' => null,
'operator' => 'eq',
],
]);
$longTimeAgo = new \DateTime('2000-01-01 00:00:00');
// The last built date is set on pre persist to 2000-01-01 00:00:00.
// Setting it 1 year ago so we could assert that it is updated after the command runs.
$segment->setLastBuiltDate($longTimeAgo);
$this->em->persist($contact);
$this->em->persist($segment);
$this->em->flush();
$this->em->clear();
Assert::assertEquals($longTimeAgo, $segment->getLastBuiltDate());
$output = $this->testSymfonyCommand(UpdateLeadListsCommand::NAME, $getCommandParams($segment));
/** @var LeadList $segment */
$segment = $this->em->find(LeadList::class, $segment->getId());
$assert($segment, $output->getDisplay());
/** @var LeadListRepository $leadListRepository */
$leadListRepository = $this->em->getRepository(LeadList::class);
Assert::assertSame(
1,
$leadListRepository->getLeadCount([$segment->getId()])
);
}
/**
* @return iterable<array<callable>>
*/
public static function Provider(): iterable
{
// Test that all segments will be rebuilt with no params set.
yield [
fn (): array => [],
function (LeadList $segment): void {
Assert::assertGreaterThan(
new \DateTime('2000-01-01 00:00:00'),
$segment->getLastBuiltDate()
);
Assert::assertNotNull($segment->getLastBuiltTime());
},
];
// Test that it will work when we select a specific segment too.
// Also testing the timing option = 0.
yield [
fn (LeadList $segment): array => ['--list-id' => $segment->getId()],
function (LeadList $segment, string $output): void {
Assert::assertGreaterThan(
new \DateTime('2000-01-01 00:00:00'),
$segment->getLastBuiltDate()
);
Assert::assertNotNull($segment->getLastBuiltTime());
Assert::assertStringNotContainsString('Total time:', $output);
},
];
// But the last built date will not update if we limit how many contacts to process.
// Also testing the timing option = 1.
yield [
fn (): array => ['--max-contacts' => 1, '--timing' => 1],
function (LeadList $segment, string $output): void {
Assert::assertEquals(
new \DateTime('2000-01-01 00:00:00'),
$segment->getLastBuiltDate()
);
Assert::assertNull($segment->getLastBuiltTime());
Assert::assertStringContainsString('Total time:', $output);
Assert::assertStringContainsString('seconds', $output);
},
];
}
/**
* @param int|string $expected
* @param array<int> $addTagsToContact
* @param array<int> $addTagsToSegment
*/
#[DataProvider('provideIncludeExclude')]
public function testTagIncludeExclude(string $filter, $expected, array $addTagsToContact, array $addTagsToSegment): void
{
$tag1 = new Tag('tag1');
$tag2 = new Tag('tag2');
$tag3 = new Tag('tag3');
$this->em->persist($tag1);
$this->em->persist($tag2);
$this->em->persist($tag3);
$this->em->flush();
$contact = $this->createLead('First name', emailId: 'halusky@bramborak.makovec');
if (in_array(1, $addTagsToContact, true)) {
$contact->addTag($tag1);
}
if (in_array(2, $addTagsToContact, true)) {
$contact->addTag($tag2);
}
if (in_array(3, $addTagsToContact, true)) {
$contact->addTag($tag3);
}
$tagSegment = [];
if (in_array(1, $addTagsToSegment, true)) {
$tagSegment[] = $tag1->getId();
}
if (in_array(2, $addTagsToSegment, true)) {
$tagSegment[] = $tag2->getId();
}
if (in_array(3, $addTagsToSegment, true)) {
$tagSegment[] = $tag3->getId();
}
$segment = $this->createSegment(
'test-segment',
[
[
'glue' => 'and',
'field' => 'tags',
'object' => 'lead',
'type' => 'tags',
'filter' => $tagSegment,
'display' => null,
'operator' => $filter,
],
]
);
$longTimeAgo = new \DateTime('2000-01-01 00:00:00');
$segment->setLastBuiltDate($longTimeAgo);
$this->em->flush();
$this->em->clear();
Assert::assertEquals($longTimeAgo, $segment->getLastBuiltDate());
$output = $this->testSymfonyCommand(UpdateLeadListsCommand::NAME);
Assert::assertSame(Command::SUCCESS, $output->getStatusCode());
/** @var LeadListRepository $leadListRepository */
$leadListRepository = $this->em->getRepository(LeadList::class);
Assert::assertSame(
$expected,
$leadListRepository->getLeadCount([$segment->getId()])
);
}
public static function provideIncludeExclude(): \Generator
{
yield 'include any with match' => [OperatorOptions::INCLUDING_ANY, 1, [1, 2], [1, 2, 3]];
yield 'include any no match' => [OperatorOptions::INCLUDING_ANY, 0, [1, 2], [3]];
yield 'exclude any with match' => [OperatorOptions::EXCLUDING_ANY, 0, [1, 2], [1, 2, 3]];
yield 'exclude any no match' => [OperatorOptions::EXCLUDING_ANY, 1, [2], [1, 3]];
yield 'include all no match' => [OperatorOptions::INCLUDING_ALL, 0, [1, 2], [1, 2, 3]];
yield 'include all with match' => [OperatorOptions::INCLUDING_ALL, 1, [1, 3], [1, 3]];
yield 'exclude all no match' => [OperatorOptions::EXCLUDING_ALL, 1, [1, 2], [1, 2, 3]];
yield 'exclude all with match' => [OperatorOptions::EXCLUDING_ALL, 0, [1, 3], [1, 3]];
}
/**
* @param int|string $expected
* @param array<int> $addFieldsToContact
* @param array<int> $addFieldsToSegment
*/
#[DataProvider('provideIncludeExclude')]
public function testCustomFieldIncludeExclude(string $filter, $expected, array $addFieldsToContact, array $addFieldsToSegment): void
{
$fieldAlias = 'test_inc_ex_field';
/** @var FieldModel $fieldModel */
$fieldModel = $this->getContainer()->get(FieldModel::class);
$fields = $fieldModel->getLeadFieldCustomFields();
Assert::assertEmpty($fields, 'There are no Custom Fields.');
// Add field.
$leadField = new LeadField();
$leadField->setName('Test Field')
->setAlias($fieldAlias)
->setType('multiselect')
->setObject('lead')
->setProperties([
'list' => [
[
'label' => 'Halusky',
'value' => 'halusky',
],
[
'label' => 'Bramborak',
'value' => 'bramborak',
],
[
'label' => 'Makovec',
'value' => 'makovec',
],
],
]);
$fieldModel->saveEntity($leadField);
$this->em->flush();
$contact = $this->createLead('First name', emailId: 'halusky@bramborak.makovec');
$contactValue = [];
if (in_array(1, $addFieldsToContact, true)) {
$contactValue[] = 'halusky';
}
if (in_array(2, $addFieldsToContact, true)) {
$contactValue[] = 'bramborak';
}
if (in_array(3, $addFieldsToContact, true)) {
$contactValue[] = 'makovec';
}
$contact->addUpdatedField($fieldAlias, $contactValue);
$contactModel = self::getContainer()->get(LeadModel::class);
\assert($contactModel instanceof LeadModel);
$contactModel->saveEntity($contact);
$segmentValue = [];
if (in_array(1, $addFieldsToSegment, true)) {
$segmentValue[] = 'halusky';
}
if (in_array(2, $addFieldsToSegment, true)) {
$segmentValue[] = 'bramborak';
}
if (in_array(3, $addFieldsToSegment, true)) {
$segmentValue[] = 'makovec';
}
$segment = $this->createSegment(
'test-segment',
[
[
'glue' => 'and',
'field' => $fieldAlias,
'object' => 'lead',
'type' => 'multiselect',
'filter' => $segmentValue,
'display' => null,
'operator' => $filter,
],
]
);
$longTimeAgo = new \DateTime('2000-01-01 00:00:00');
$segment->setLastBuiltDate($longTimeAgo);
$this->em->flush();
$this->em->clear();
Assert::assertEquals($longTimeAgo, $segment->getLastBuiltDate());
$output = $this->testSymfonyCommand(UpdateLeadListsCommand::NAME);
Assert::assertSame(Command::SUCCESS, $output->getStatusCode());
/** @var LeadListRepository $leadListRepository */
$leadListRepository = $this->em->getRepository(LeadList::class);
Assert::assertSame(
$expected,
$leadListRepository->getLeadCount([$segment->getId()])
);
}
/**
* @param int|string $expected
* @param array<int> $addSegmentsToContact
* @param array<int> $addSegmentsToSegment
*/
#[DataProvider('provideIncludeExclude')]
public function testSegmentIncludeExclude(string $filter, $expected, array $addSegmentsToContact, array $addSegmentsToSegment): void
{
$contact = $this->createLead('First name', emailId: 'halusky@bramborak.makovec');
$segmentA = $this->createSegment('A', []);
$segmentB = $this->createSegment('B', []);
$segmentC = $this->createSegment('C', []);
$this->em->flush();
if (in_array(1, $addSegmentsToContact, true)) {
$this->createListLead($segmentA, $contact);
}
if (in_array(2, $addSegmentsToContact, true)) {
$this->createListLead($segmentB, $contact);
}
if (in_array(3, $addSegmentsToContact, true)) {
$this->createListLead($segmentC, $contact);
}
$filteredSegments = [];
if (in_array(1, $addSegmentsToSegment, true)) {
$filteredSegments[] = $segmentA->getId();
}
if (in_array(2, $addSegmentsToSegment, true)) {
$filteredSegments[] = $segmentB->getId();
}
if (in_array(3, $addSegmentsToSegment, true)) {
$filteredSegments[] = $segmentC->getId();
}
$segmentD = $this->createSegment(
'D',
[
[
'glue' => 'and',
'field' => 'leadlist',
'object' => 'lead',
'type' => 'leadlist',
'filter' => $filteredSegments,
'display' => null,
'operator' => $filter,
],
]
);
$longTimeAgo = new \DateTime('2000-01-01 00:00:00');
$segmentD->setLastBuiltDate($longTimeAgo);
$this->em->flush();
$this->em->clear();
Assert::assertEquals($longTimeAgo, $segmentD->getLastBuiltDate());
$output = $this->testSymfonyCommand(UpdateLeadListsCommand::NAME);
Assert::assertSame(Command::SUCCESS, $output->getStatusCode());
/** @var LeadListRepository $leadListRepository */
$leadListRepository = $this->em->getRepository(LeadList::class);
Assert::assertSame(
$expected,
$leadListRepository->getLeadCount([$segmentD->getId()])
);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\UpdateLeadListsCommand;
use Mautic\LeadBundle\Entity\LeadList;
class UpdateLeadListsCommandCircularDependencyTest extends MauticMysqlTestCase
{
/**
* @var LeadList[]
*/
private array $segments = [];
protected function setUp(): void
{
parent::setUp();
$this->createCircularDependencySegments();
}
public function testCircularDependencyDetection(): void
{
$segmentA = $this->segments['Segment A'];
$this->expectException(\Mautic\LeadBundle\Segment\Exception\SegmentQueryException::class);
$this->expectExceptionMessage('Circular reference detected.');
$this->testSymfonyCommand(
UpdateLeadListsCommand::NAME,
[
'-i' => $segmentA->getId(),
'--env' => 'test',
]
);
}
/**
* Creates segments with circular dependencies:
* Segment A includes Segment B
* Segment B includes Segment C
* Segment C includes Segment A
*/
private function createCircularDependencySegments(): void
{
// Create three test segments
$segmentA = $this->createSegment('Segment A');
$segmentB = $this->createSegment('Segment B');
$segmentC = $this->createSegment('Segment C');
$this->em->flush();
// Add filters to create circular dependencies
// Segment A includes Segment B
$this->addSegmentDependency($segmentA, $segmentB);
// Segment B includes Segment C
$this->addSegmentDependency($segmentB, $segmentC);
// Segment C includes Segment A (creating the circular dependency)
$this->addSegmentDependency($segmentC, $segmentA);
$this->em->flush();
// Store segments for later use
$this->segments = [
'Segment A' => $segmentA,
'Segment B' => $segmentB,
'Segment C' => $segmentC,
];
}
private function createSegment(string $name): LeadList
{
$segment = new LeadList();
$segment->setName($name);
$segment->setPublicName($name);
$segment->setAlias(strtolower(str_replace(' ', '-', $name)));
$segment->setIsGlobal(true);
$segment->setIsPublished(true);
$this->em->persist($segment);
return $segment;
}
private function addSegmentDependency(LeadList $segment, LeadList $includeSegment): void
{
$filters = $segment->getFilters();
$filters[] = [
'glue' => 'and',
'field' => 'leadlist',
'object' => 'lead',
'type' => 'leadlist',
'filter' => [$includeSegment->getId()],
'display' => null,
'operator' => 'in',
];
$segment->setFilters($filters);
$this->em->persist($segment);
}
public function testSkippingNonExistentDependentSegment(): void
{
// Create two segments (A and B)
$segmentA = $this->createSegment('Segment A');
$segmentB = $this->createSegment('Segment B');
$this->em->flush();
// Add a filter to segment A that includes segment B
$this->addSegmentDependency($segmentA, $segmentB);
// Add a non-existent segment ID as a dependency for segment A
$nonExistentSegmentId = 9999; // An ID that doesn't exist
$filters = $segmentA->getFilters();
$filters[] = [
'glue' => 'and',
'field' => 'leadlist',
'object' => 'lead',
'type' => 'leadlist',
'filter' => [$nonExistentSegmentId],
'display' => null,
'operator' => 'in',
];
$segmentA->setFilters($filters);
$this->em->persist($segmentA);
$this->em->flush();
$this->segments = [
'Segment A' => $segmentA,
'Segment B' => $segmentB,
];
// The command should complete without errors despite the non-existent segment dependency
$output = $this->testSymfonyCommand(
UpdateLeadListsCommand::NAME,
[
'-i' => $segmentA->getId(),
'--env' => 'test',
]
);
// Verify that segment B was processed
$this->assertStringContainsString(
sprintf('Rebuilding contacts for segment %d', $segmentB->getId()),
$output->getDisplay()
);
// Verify that segment A was processed after its dependencies
$this->assertStringContainsString(
sprintf('Rebuilding contacts for segment %d', $segmentA->getId()),
$output->getDisplay()
);
// Verify that the command completed successfully
$this->assertStringNotContainsString('error', strtolower($output->getDisplay()));
}
}