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,265 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Doctrine\ORM\ORMException;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadCategory;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\ListLead;
use PHPUnit\Framework\Assert;
class EmailRepositoryFunctionalTest extends MauticMysqlTestCase
{
private EmailRepository $emailRepository;
protected function setUp(): void
{
parent::setUp();
/** @var EmailRepository $repository */
$repository = $this->em->getRepository(Email::class);
$this->emailRepository = $repository;
}
public function testGetDoNotEmailListEmpty(): void
{
$result = $this->emailRepository->getDoNotEmailList();
Assert::assertSame([], $result);
}
public function testGetDoNotEmailListNotEmpty(): void
{
$lead = new Lead();
$lead->setEmail('name@domain.tld');
$this->em->persist($lead);
$doNotContact = new DoNotContact();
$doNotContact->setLead($lead);
$doNotContact->setDateAdded(new \DateTime());
$doNotContact->setChannel('email');
$this->em->persist($doNotContact);
$this->em->flush();
// no $leadIds
$result = $this->emailRepository->getDoNotEmailList();
Assert::assertSame([$lead->getId() => $lead->getEmail()], $result);
// matching $leadIds
$result = $this->emailRepository->getDoNotEmailList([$lead->getId()]);
Assert::assertSame([$lead->getId() => $lead->getEmail()], $result);
// mismatching $leadIds
$result = $this->emailRepository->getDoNotEmailList([-1]);
Assert::assertSame([], $result);
}
public function testCheckDoNotEmailNonExistent(): void
{
$result = $this->emailRepository->checkDoNotEmail('name@domain.tld');
Assert::assertFalse($result);
}
public function testCheckDoNotEmailExistent(): void
{
$lead = new Lead();
$lead->setEmail('name@domain.tld');
$this->em->persist($lead);
$doNotContact = new DoNotContact();
$doNotContact->setLead($lead);
$doNotContact->setDateAdded(new \DateTime());
$doNotContact->setChannel('email');
$doNotContact->setReason(1);
$doNotContact->setComments('Some comment');
$this->em->persist($doNotContact);
$this->em->flush();
$result = $this->emailRepository->checkDoNotEmail('name@domain.tld');
Assert::assertNotFalse($result);
Assert::assertSame([
'id' => (string) $doNotContact->getId(),
'unsubscribed' => true,
'bounced' => false,
'manual' => false,
'comments' => $doNotContact->getComments(),
], $result);
}
public function testGetEmailPendingQueryWithSubscribedCategory(): void
{
// create some leads
$leadOne = $this->createLead('one');
$leadTwo = $this->createLead('two');
$leadThree = $this->createLead('three');
$leadFour = $this->createLead('four');
// create some categories
$catOne = $this->createCategory('one');
$catTwo = $this->createCategory('two');
$catThree = $this->createCategory('three');
// lead to subscribe categories
$this->subscribeCategory($leadOne, true, $catOne, $catTwo);
$this->subscribeCategory($leadTwo, true, $catOne, $catThree);
$this->subscribeCategory($leadThree, true, $catTwo, $catThree);
$this->subscribeCategory($leadFour, true, $catOne, $catThree);
// lead to unsubscribe categories
$this->subscribeCategory($leadOne, false, $catThree);
$sourceListOne = $this->createLeadList('Source', $leadOne, $leadTwo, $leadThree, $leadFour);
// create an email with included/excluded lists
$email = new Email();
$email->setName('Email');
$email->setSubject('Subject');
$email->setEmailType('list');
$email->addList($sourceListOne);
$email->setCategory($catThree);
$this->em->persist($email);
$this->em->flush();
$this->em->clear();
$result = $this->emailRepository->getEmailPendingQuery($email->getId())
->executeQuery()
->fetchAllAssociative();
$actualLeadIds = array_map('intval', array_column($result, 'id'));
sort($actualLeadIds);
$expectedLeadIds = [$leadTwo->getId(), $leadThree->getId(), $leadFour->getId()];
sort($expectedLeadIds);
$this->assertSame($expectedLeadIds, $actualLeadIds);
}
public function testGetEmailPendingQueryWithExcludedLists(): void
{
// create some leads
$leadOne = $this->createLead('one');
$leadTwo = $this->createLead('two');
$leadThree = $this->createLead('three');
$leadFour = $this->createLead('four');
$leadFive = $this->createLead('five');
$leadSix = $this->createLead('six');
// add some leads in lists for inclusion
$sourceListOne = $this->createLeadList('Source', $leadOne, $leadTwo, $leadThree);
$sourceListTwo = $this->createLeadList('Source', $leadOne, $leadFour, $leadFive, $leadSix);
// add some leads in lists for exclusion
$excludeListOne = $this->createLeadList('Exclude', $leadTwo, $leadSix);
$excludeListTwo = $this->createLeadList('Exclude', $leadTwo, $leadThree);
// create an email with included/excluded lists
$email = new Email();
$email->setName('Email');
$email->setSubject('Subject');
$email->setEmailType('list');
$email->addList($sourceListOne);
$email->addList($sourceListTwo);
$email->addExcludedList($excludeListOne);
$email->addExcludedList($excludeListTwo);
$this->em->persist($email);
$this->em->flush();
$this->em->clear();
$actualLeadIds = $this->emailRepository->getEmailPendingQuery($email->getId())
->executeQuery()
->fetchFirstColumn();
sort($actualLeadIds);
$expectedLeadIds = [$leadOne->getId(), $leadFour->getId(), $leadFive->getId()];
$expectedLeadIds = array_map(fn (int $id) => (string) $id, $expectedLeadIds);
sort($expectedLeadIds);
Assert::assertSame($expectedLeadIds, $actualLeadIds);
}
/**
* @throws ORMException
*/
private function createLead(string $lastName): Lead
{
$lead = new Lead();
$lead->setLastname($lastName);
$lead->setEmail(sprintf('%s@mail.tld', $lastName));
$this->em->persist($lead);
return $lead;
}
/**
* @param Lead ...$leads
*
* @throws ORMException
*/
private function createLeadList(string $name, ...$leads): LeadList
{
$leadList = new LeadList();
$leadList->setName($name);
$leadList->setPublicName($name);
$leadList->setAlias(mb_strtolower($name));
$this->em->persist($leadList);
foreach ($leads as $lead) {
$this->addLeadToList($lead, $leadList);
}
return $leadList;
}
private function addLeadToList(Lead $leadOne, LeadList $sourceList): void
{
$listLead = new ListLead();
$listLead->setLead($leadOne);
$listLead->setList($sourceList);
$listLead->setDateAdded(new \DateTime());
$this->em->persist($listLead);
}
private function createCategory(string $string): Category
{
$category = new Category();
$category->setTitle('Category '.$string);
$category->setAlias('category-'.$string);
$category->setBundle('global');
$this->em->persist($category);
return $category;
}
/**
* @param Category ...$categories
*/
private function subscribeCategory(Lead $lead, bool $subscribed, ...$categories): void
{
foreach ($categories as $category) {
$leadCategory = new LeadCategory();
$leadCategory->setLead($lead);
$leadCategory->setCategory($category);
$leadCategory->setDateAdded(new \DateTime());
$leadCategory->setManuallyAdded($subscribed);
$leadCategory->setManuallyRemoved(!$subscribed);
$this->em->persist($leadCategory);
}
$this->em->flush();
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Test\Doctrine\RepositoryConfiguratorTrait;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
class EmailRepositoryIncrementReadTest extends \PHPUnit\Framework\TestCase
{
use RepositoryConfiguratorTrait;
private QueryBuilder $queryBuilder;
private QueryBuilder $subQueryBuilder;
/**
* @var EmailRepository|object
*/
private $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = $this->configureRepository(Email::class);
$this->queryBuilder = new QueryBuilder($this->connection);
$this->subQueryBuilder = new QueryBuilder($this->connection);
$this->connection->method('createQueryBuilder')->willReturnOnConsecutiveCalls(
$this->queryBuilder,
$this->subQueryBuilder
);
}
public function testIncrementRead(): void
{
$this->connection
->expects($this->exactly(1))
->method('executeStatement')
->willReturn(1);
$this->repo->incrementRead(11, '21');
$generatedSql = $this->queryBuilder->getSQL();
// Assert that the generated SQL matches our expectations
$expectedSql = 'UPDATE test_emails e SET read_count = read_count + 1 WHERE (e.id = :emailId) AND (e.id NOT IN (SELECT es.email_id FROM test_email_stats es WHERE (es.id = :statId) AND (es.is_read = 1)))';
$this->assertEquals($expectedSql, $generatedSql);
}
public function testIncrementReadWithVariant(): void
{
$this->connection
->expects($this->exactly(1))
->method('executeStatement')
->willReturn(1);
$this->repo->incrementRead(11, '21', true);
$generatedSql = $this->queryBuilder->getSQL();
// Assert that the generated SQL matches our expectations
$expectedSql = 'UPDATE test_emails e SET read_count = read_count + 1, variant_read_count = variant_read_count + 1 WHERE (e.id = :emailId) AND (e.id NOT IN (SELECT es.email_id FROM test_email_stats es WHERE (es.id = :statId) AND (es.is_read = 1)))';
$this->assertEquals($expectedSql, $generatedSql);
}
public function testUpCountWithTwoErrors(): void
{
$this->connection
->expects($this->exactly(3))
->method('executeStatement')
->willReturnOnConsecutiveCalls(
$this->throwException(new DBALException()),
$this->throwException(new DBALException()),
1
);
$this->repo->incrementRead(45, '616');
}
public function testUpCountWithFourErrors(): void
{
$this->connection
->expects($this->exactly(3))
->method('executeStatement')
->will($this->throwException(new DBALException()));
$this->expectException(DBALException::class);
$this->repo->incrementRead(45, '616');
}
}

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Result;
use Mautic\CoreBundle\Test\Doctrine\RepositoryConfiguratorTrait;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
use Mautic\LeadBundle\Entity\DoNotContact;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class EmailRepositoryTest extends TestCase
{
use RepositoryConfiguratorTrait;
private EmailRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = $this->configureRepository(Email::class);
$this->connection->method('createQueryBuilder')->willReturnCallback(fn () => new QueryBuilder($this->connection));
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnCallback(fn ($id) => match ($id) {
'mautic.email.email.searchcommand.isexpired' => 'is:expired',
'mautic.email.email.searchcommand.ispending' => 'is:pending',
default => $id,
});
$this->repo->setTranslator($translator);
}
/**
* @param int[] $variantIds
* @param int[] $excludedListIds
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataGetEmailPendingQueryForCount')]
public function testGetEmailPendingQueryForCount(?array $variantIds, bool $countWithMaxMin, array $excludedListIds, string $expectedQuery): void
{
$this->mockExcludedListIds($excludedListIds);
$emailId = 5;
$listIds = [22, 33];
$countOnly = true;
$limit = null;
$minContactId = null;
$maxContactId = null;
$query = $this->repo->getEmailPendingQuery(
$emailId,
$variantIds,
$listIds,
$countOnly,
$limit,
$minContactId,
$maxContactId,
$countWithMaxMin
);
$this->assertEquals($this->replaceQueryPrefix($expectedQuery), $query->getSql());
$this->assertEquals(['false' => false], $query->getParameters());
}
/**
* @return iterable<mixed[]>
*/
public static function dataGetEmailPendingQueryForCount(): iterable
{
yield [null, false, [], "SELECT count(*) as count FROM test_leads l WHERE (l.id IN (SELECT ll.lead_id FROM test_lead_lists_leads ll WHERE (ll.lead_id = l.id) AND (ll.leadlist_id IN (22, 33)) AND (ll.manually_removed = :false))) AND (l.id NOT IN (SELECT dnc.lead_id FROM test_lead_donotcontact dnc WHERE (dnc.lead_id = l.id) AND (dnc.channel = 'email'))) AND (l.id NOT IN (SELECT stat.lead_id FROM test_email_stats stat WHERE (stat.lead_id IS NOT NULL) AND (stat.email_id = 5))) AND (l.id NOT IN (SELECT mq.lead_id FROM test_message_queue mq WHERE (mq.lead_id = l.id) AND (mq.status <> 'sent') AND (mq.channel = 'email') AND (mq.channel_id = 5))) AND (l.id NOT IN (SELECT lc.lead_id FROM test_lead_categories lc INNER JOIN test_emails e ON e.category_id = lc.category_id WHERE (e.id = 5) AND (lc.manually_removed = 1))) AND ((l.email IS NOT NULL) AND (l.email <> ''))"];
yield [[6], false, [16], "SELECT count(*) as count FROM test_leads l WHERE (l.id IN (SELECT ll.lead_id FROM test_lead_lists_leads ll WHERE (ll.lead_id = l.id) AND (ll.leadlist_id IN (22, 33)) AND (ll.manually_removed = :false))) AND (l.id NOT IN (SELECT dnc.lead_id FROM test_lead_donotcontact dnc WHERE (dnc.lead_id = l.id) AND (dnc.channel = 'email'))) AND (l.id NOT IN (SELECT stat.lead_id FROM test_email_stats stat WHERE (stat.lead_id IS NOT NULL) AND (stat.email_id IN (6, 5)))) AND (l.id NOT IN (SELECT mq.lead_id FROM test_message_queue mq WHERE (mq.lead_id = l.id) AND (mq.status <> 'sent') AND (mq.channel = 'email') AND (mq.channel_id IN (6, 5)))) AND (l.id NOT IN (SELECT lc.lead_id FROM test_lead_categories lc INNER JOIN test_emails e ON e.category_id = lc.category_id WHERE (e.id = 5) AND (lc.manually_removed = 1))) AND ((l.email IS NOT NULL) AND (l.email <> ''))"];
yield [null, true, [9, 7], "SELECT count(*) as count, MIN(l.id) as min_id, MAX(l.id) as max_id FROM test_leads l WHERE (l.id IN (SELECT ll.lead_id FROM test_lead_lists_leads ll WHERE (ll.lead_id = l.id) AND (ll.leadlist_id IN (22, 33)) AND (ll.manually_removed = :false))) AND (l.id NOT IN (SELECT dnc.lead_id FROM test_lead_donotcontact dnc WHERE (dnc.lead_id = l.id) AND (dnc.channel = 'email'))) AND (l.id NOT IN (SELECT stat.lead_id FROM test_email_stats stat WHERE (stat.lead_id IS NOT NULL) AND (stat.email_id = 5))) AND (l.id NOT IN (SELECT mq.lead_id FROM test_message_queue mq WHERE (mq.lead_id = l.id) AND (mq.status <> 'sent') AND (mq.channel = 'email') AND (mq.channel_id = 5))) AND (l.id NOT IN (SELECT lc.lead_id FROM test_lead_categories lc INNER JOIN test_emails e ON e.category_id = lc.category_id WHERE (e.id = 5) AND (lc.manually_removed = 1))) AND ((l.email IS NOT NULL) AND (l.email <> ''))"];
}
/**
* @param int[] $excludedListIds
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataGetEmailPendingQueryForMaxMinIdCountWithMaxMinIdsDefined')]
public function testGetEmailPendingQueryForMaxMinIdCountWithMaxMinIdsDefined(array $excludedListIds, string $expectedQuery): void
{
$this->mockExcludedListIds($excludedListIds);
$emailId = 5;
$variantIds = null;
$listIds = [22, 33];
$countOnly = true;
$limit = null;
$minContactId = 10;
$maxContactId = 1000;
$countWithMaxMin = true;
$query = $this->repo->getEmailPendingQuery(
$emailId,
$variantIds,
$listIds,
$countOnly,
$limit,
$minContactId,
$maxContactId,
$countWithMaxMin
);
$expectedParams = [
'false' => false,
'minContactId' => 10,
'maxContactId' => 1000,
];
$this->assertEquals($this->replaceQueryPrefix($expectedQuery), $query->getSql());
$this->assertEquals($expectedParams, $query->getParameters());
}
/**
* @return iterable<mixed[]>
*/
public static function dataGetEmailPendingQueryForMaxMinIdCountWithMaxMinIdsDefined(): iterable
{
yield [[], "SELECT count(*) as count, MIN(l.id) as min_id, MAX(l.id) as max_id FROM test_leads l WHERE (l.id IN (SELECT ll.lead_id FROM test_lead_lists_leads ll WHERE (ll.lead_id = l.id) AND (ll.leadlist_id IN (22, 33)) AND (ll.manually_removed = :false))) AND (l.id NOT IN (SELECT dnc.lead_id FROM test_lead_donotcontact dnc WHERE (dnc.lead_id = l.id) AND (dnc.channel = 'email'))) AND (l.id NOT IN (SELECT stat.lead_id FROM test_email_stats stat WHERE (stat.lead_id IS NOT NULL) AND (stat.email_id = 5))) AND (l.id NOT IN (SELECT mq.lead_id FROM test_message_queue mq WHERE (mq.lead_id = l.id) AND (mq.status <> 'sent') AND (mq.channel = 'email') AND (mq.channel_id = 5))) AND (l.id NOT IN (SELECT lc.lead_id FROM test_lead_categories lc INNER JOIN test_emails e ON e.category_id = lc.category_id WHERE (e.id = 5) AND (lc.manually_removed = 1))) AND (l.id >= :minContactId) AND (l.id <= :maxContactId) AND ((l.email IS NOT NULL) AND (l.email <> ''))"];
yield [[96, 98, 103], "SELECT count(*) as count, MIN(l.id) as min_id, MAX(l.id) as max_id FROM test_leads l WHERE (l.id IN (SELECT ll.lead_id FROM test_lead_lists_leads ll WHERE (ll.lead_id = l.id) AND (ll.leadlist_id IN (22, 33)) AND (ll.manually_removed = :false))) AND (l.id NOT IN (SELECT dnc.lead_id FROM test_lead_donotcontact dnc WHERE (dnc.lead_id = l.id) AND (dnc.channel = 'email'))) AND (l.id NOT IN (SELECT stat.lead_id FROM test_email_stats stat WHERE (stat.lead_id IS NOT NULL) AND (stat.email_id = 5))) AND (l.id NOT IN (SELECT mq.lead_id FROM test_message_queue mq WHERE (mq.lead_id = l.id) AND (mq.status <> 'sent') AND (mq.channel = 'email') AND (mq.channel_id = 5))) AND (l.id NOT IN (SELECT lc.lead_id FROM test_lead_categories lc INNER JOIN test_emails e ON e.category_id = lc.category_id WHERE (e.id = 5) AND (lc.manually_removed = 1))) AND (l.id >= :minContactId) AND (l.id <= :maxContactId) AND ((l.email IS NOT NULL) AND (l.email <> ''))"];
}
public function testGetUniqueCliks(): void
{
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('select')
->with('SUM( tr.unique_hits) as `unique_clicks`')
->willReturnSelf();
$resultMock = $this->createMock(Result::class);
$queryBuilder->expects($this->once())
->method('executeQuery')
->willReturn($resultMock);
$resultMock->expects($this->once())
->method('fetchOne')
->willReturn(10);
$repository = $this->getMockBuilder(EmailRepository::class)
->disableOriginalConstructor()
->onlyMethods(['addTrackableTablesForEmailStats'])
->getMock();
$result = $repository->getUniqueClicks($queryBuilder);
$this->assertEquals(10, $result);
}
public function testGetUnsubscribedCount(): void
{
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('resetQueryParts')
->with(['join'])
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('select')
->with('e.id as email_id, dnc.lead_id')
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('andWhere')
->with('dnc.reason='.DoNotContact::UNSUBSCRIBED)
->willReturnSelf();
$resultMock = $this->createMock(Result::class);
$queryBuilder->expects($this->once())
->method('executeQuery')
->willReturn($resultMock);
$resultMock->expects($this->once())
->method('rowCount')
->willReturn(5);
$repository = $this->getMockBuilder(EmailRepository::class)
->disableOriginalConstructor()
->onlyMethods(['addDNCTableForEmails'])
->getMock();
$result = $repository->getUnsubscribedCount($queryBuilder);
$this->assertEquals(5, $result);
}
public function testGetSentReadNotReadCount(): void
{
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('resetQueryPart')
->with('groupBy')
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('resetQueryParts')
->with(['join'])
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('select')
->with('SUM( e.sent_count) as sent_count, SUM( e.read_count) as read_count')
->willReturnSelf();
$resultMock = $this->createMock(Result::class);
$queryBuilder->expects($this->once())
->method('executeQuery')
->willReturn($resultMock);
$resultMock->expects($this->once())
->method('fetchAssociative')
->willReturn([
'sent_count' => '100',
'read_count' => '60',
]);
$result = $this->repo->getSentReadNotReadCount($queryBuilder);
$this->assertEquals([
'sent_count' => 100,
'read_count' => 60,
'not_read' => 40,
], $result);
}
public function testGetSentReadNotReadCountEmptyResults(): void
{
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('resetQueryPart')
->with('groupBy')
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('resetQueryParts')
->with(['join'])
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('select')
->with('SUM( e.sent_count) as sent_count, SUM( e.read_count) as read_count')
->willReturnSelf();
$resultMock = $this->createMock(Result::class);
$queryBuilder->expects($this->once())
->method('executeQuery')
->willReturn($resultMock);
$resultMock->expects($this->once())
->method('fetchAssociative')
->willReturn(false);
$result = $this->repo->getSentReadNotReadCount($queryBuilder);
$this->assertEquals([
'sent_count' => 0,
'read_count' => 0,
'not_read' => 0,
], $result);
}
/**
* @param int[] $excludedListIds
*/
private function mockExcludedListIds(array $excludedListIds): void
{
$resultMock = $this->createMock(Result::class);
$resultMock->method('fetchAllAssociative')
->willReturn(array_map(fn (int $id) => [$id], $excludedListIds));
$this->connection->method('executeQuery')
->willReturn($resultMock);
}
private function replaceQueryPrefix(string $query): string
{
return str_replace('{prefix}', MAUTIC_TABLE_PREFIX, $query);
}
public function testAddSearchCommandWhereClauseHandlesExpirationFilters(): void
{
$qb = $this->connection->createQueryBuilder();
$filter = (object) ['command' => 'is:expired', 'string' => '', 'not' => false, 'strict' => false];
$method = new \ReflectionMethod(EmailRepository::class, 'addSearchCommandWhereClause');
$method->setAccessible(true);
[$expr, $params] = $method->invoke($this->repo, $qb, $filter);
self::assertSame(
'(e.isPublished = :par1 AND e.publishDown IS NOT NULL AND e.publishDown <> \'\' AND e.publishDown < CURRENT_TIMESTAMP())',
(string) $expr
);
self::assertSame(['par1' => true], $params);
}
public function testAddSearchCommandWhereClauseHandlesPendingFilters(): void
{
$qb = $this->connection->createQueryBuilder();
$filter = (object) ['command' => 'is:pending', 'string' => '', 'not' => false, 'strict' => false];
$method = new \ReflectionMethod(EmailRepository::class, 'addSearchCommandWhereClause');
$method->setAccessible(true);
[$expr, $params] = $method->invoke($this->repo, $qb, $filter);
self::assertSame(
'(e.isPublished = :par1 AND e.publishUp IS NOT NULL AND e.publishUp <> \'\' AND e.publishUp > CURRENT_TIMESTAMP())',
(string) $expr
);
self::assertSame(['par1' => true], $params);
}
public function testGetSearchCommandsContainsExpirationFilters(): void
{
$commands = $this->repo->getSearchCommands();
self::assertContains('mautic.email.email.searchcommand.isexpired', $commands);
self::assertContains('mautic.email.email.searchcommand.ispending', $commands);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Test\Doctrine\RepositoryConfiguratorTrait;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
use PHPUnit\Framework\MockObject\MockObject;
class EmailRepositoryUpCountSentTest extends \PHPUnit\Framework\TestCase
{
use RepositoryConfiguratorTrait;
/**
* @var MockObject|QueryBuilder
*/
private MockObject $queryBuilderMock;
private QueryBuilder $queryBuilder;
private EmailRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = $this->configureRepository(Email::class);
$this->queryBuilderMock = $this->createMock(QueryBuilder::class);
$this->queryBuilder = new QueryBuilder($this->connection);
}
public function testUpCountSentWithNoIncrease(): void
{
$this->connection->method('createQueryBuilder')->willReturn($this->queryBuilderMock);
$this->queryBuilderMock->expects($this->never())
->method('update');
$this->repo->upCountSent(45, 0);
}
public function testUpCountSentWithId(): void
{
$this->connection->method('createQueryBuilder')->willReturn($this->queryBuilder);
$this->connection
->expects($this->exactly(1))
->method('executeStatement')
->willReturn(1);
$this->repo->upCountSent(11);
$generatedSql = $this->queryBuilder->getSQL();
// Assert that the generated SQL matches our expectations
$expectedSql = 'UPDATE test_emails SET sent_count = sent_count + :increaseBy WHERE id = :id';
$this->assertEquals($expectedSql, $generatedSql);
// Assert parameters are properly set up
$this->assertEquals(11, $this->queryBuilder->getParameter('id'));
$this->assertEquals(1, $this->queryBuilder->getParameter('increaseBy'));
}
public function testUpCountWithVariant(): void
{
$this->connection->method('createQueryBuilder')->willReturn($this->queryBuilder);
$this->connection
->expects($this->exactly(1))
->method('executeStatement')
->willReturn(1);
$this->repo->upCountSent(11, 2, true);
$generatedSql = $this->queryBuilder->getSQL();
// Assert that the generated SQL matches our expectations
$expectedSql = 'UPDATE test_emails SET sent_count = sent_count + :increaseBy, variant_sent_count = variant_sent_count + :increaseBy WHERE id = :id';
$this->assertEquals($expectedSql, $generatedSql);
}
public function testUpCountWithTwoErrors(): void
{
$this->connection->method('createQueryBuilder')->willReturn($this->queryBuilder);
$this->connection
->expects($this->exactly(3))
->method('executeStatement')
->willReturnOnConsecutiveCalls(
$this->throwException(new DBALException()),
$this->throwException(new DBALException()),
1
);
$this->repo->upCountSent(45);
}
public function testUpCountWithFourErrors(): void
{
$this->connection->method('createQueryBuilder')->willReturn($this->queryBuilder);
$this->connection
->expects($this->exactly(3))
->method('executeStatement')
->will($this->throwException(new DBALException()));
$this->expectException(DBALException::class);
$this->repo->upCountSent(45);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Mautic\EmailBundle\Entity\Email;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class EmailTest extends TestCase
{
public function testCloneResetPublishDates(): void
{
$email = new Email();
$email->setPublishUp(new \DateTime());
$email->setPublishDown(new \DateTime());
$emailClone = clone $email;
$this->assertNull($emailClone->getPublishUp());
$this->assertNull($emailClone->getPublishDown());
}
public function testCloneResetPlainText(): void
{
$email = new Email();
$email->setPlainText('foo');
$emailClone = clone $email;
$this->assertNull($emailClone->getPlainText());
}
#[DataProvider('setIsDuplicateDataProvider')]
public function testIsDuplicate(bool $isDuplicate): void
{
$email = new Email();
$email->setIsDuplicate($isDuplicate);
Assert::assertIsBool($email->isDuplicate());
}
/**
* @return iterable<array{bool}>
*/
public static function setIsDuplicateDataProvider(): iterable
{
yield [true];
yield [false];
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\ListLead;
use Mautic\LeadBundle\Model\LeadModel;
use PHPUnit\Framework\Assert;
/**
* This test ensures that the pending query will work even if a contact was deleted between batches.
* After the refactoring from NOT EXISTS to NOT IN the single deleted contact could cause the
* pending query to find no contacts due to null value in the lead_id column.
*/
class PendingQueryFunctionalTest extends MauticMysqlTestCase
{
public function testDelayedSends(): void
{
$emailRepository = $this->em->getRepository(Email::class);
$contactCount = 4;
$oneBatchCount = $contactCount / 2;
$contacts = $this->generateContacts($contactCount);
$batch1 = array_slice($contacts, 0, $oneBatchCount);
$segment = $this->createSegment();
$email = $this->createEmail($segment);
$this->addContactsToSegment($contacts, $segment);
Assert::assertSame($contactCount, (int) $emailRepository->getEmailPendingLeads($email->getId(), null, null, true));
$this->emulateEmailSend($email, $batch1);
Assert::assertSame($oneBatchCount, (int) $emailRepository->getEmailPendingLeads($email->getId(), null, null, true));
$this->em->remove($batch1[0]);
$this->em->flush();
// The pending count must be the same even if one of the email_stat records has lead_id = null.
Assert::assertSame($oneBatchCount, (int) $emailRepository->getEmailPendingLeads($email->getId(), null, null, true));
}
/**
* @return Lead[]
*/
private function generateContacts(int $howMany): array
{
$contacts = [];
for ($i = 0; $i < $howMany; ++$i) {
$contact = new Lead();
$contact->setEmail("test{$i}@some.email");
$contacts[] = $contact;
}
$contactModel = static::getContainer()->get('mautic.lead.model.lead');
\assert($contactModel instanceof LeadModel);
$contactModel->saveEntities($contacts);
return $contacts;
}
private function createSegment(): LeadList
{
$segment = new LeadList();
$segment->setName('Segment A');
$segment->setPublicName('Segment A');
$segment->setAlias('segment-a');
$this->em->persist($segment);
$this->em->flush();
return $segment;
}
/**
* @param Lead[] $contacts
*/
private function addContactsToSegment(array $contacts, LeadList $segment): void
{
foreach ($contacts as $contact) {
$reference = new ListLead();
$reference->setLead($contact);
$reference->setList($segment);
$reference->setDateAdded(new \DateTime());
$this->em->persist($reference);
}
$this->em->flush();
}
private function createEmail(LeadList $segment): Email
{
$email = new Email();
$email->setName('Email A');
$email->setSubject('Email A Subject');
$email->setEmailType('list');
$email->addList($segment);
$this->em->persist($email);
$this->em->flush();
return $email;
}
/**
* @param Lead[] $contacts
*/
private function emulateEmailSend(Email $email, array $contacts): void
{
foreach ($contacts as $contact) {
$emailStat = new Stat();
$emailStat->setEmail($email);
$emailStat->setEmailAddress($contact->getEmail());
$emailStat->setLead($contact);
$emailStat->setDateSent(new \DateTime());
$this->em->persist($emailStat);
}
$this->em->flush();
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Test\Doctrine\RepositoryConfiguratorTrait;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
final class StatRepositoryTest extends \PHPUnit\Framework\TestCase
{
use RepositoryConfiguratorTrait;
private StatRepository $statRepository;
protected function setUp(): void
{
parent::setUp();
$this->statRepository = $this->configureRepository(Stat::class);
$this->connection->method('createQueryBuilder')->willReturnCallback(fn () => new QueryBuilder($this->connection));
}
public function testGetStatsSummaryForContacts(): void
{
$expectedQuery = 'SELECT l.id AS `lead_id`, COUNT(es.id) AS `sent_count`, SUM(IF(es.is_read IS NULL, 0, es.is_read)) AS `read_count`, SUM(IF(sq.hits is NULL, 0, 1)) AS `clicked_through_count` FROM '.MAUTIC_TABLE_PREFIX.'email_stats es RIGHT JOIN '.MAUTIC_TABLE_PREFIX.'leads l ON es.lead_id=l.id LEFT JOIN (SELECT COUNT(ph.id) AS hits, COUNT(DISTINCT(ph.redirect_id)) AS unique_hits, cut.channel_id, ph.lead_id FROM '.MAUTIC_TABLE_PREFIX.'channel_url_trackables cut INNER JOIN '.MAUTIC_TABLE_PREFIX."page_hits ph ON cut.redirect_id = ph.redirect_id AND cut.channel_id = ph.source_id WHERE (cut.channel = 'email' AND ph.source = 'email') AND (ph.lead_id in (:contacts)) GROUP BY cut.channel_id, ph.lead_id) sq ON es.email_id = sq.channel_id AND es.lead_id = sq.lead_id WHERE l.id in (:contacts) GROUP BY l.id";
$this->connection->expects($this->once())
->method('executeQuery')
->with(
$expectedQuery,
['contacts' => [6, 8]],
['contacts' => 101]
)
->willReturn($this->result);
$this->result->method('fetchAllAssociative')
->willReturn([
[
'lead_id' => '6',
'sent_count' => '12',
'read_count' => '6',
'clicked_through_count' => '3',
],
[
'lead_id' => '8',
'sent_count' => '13',
'read_count' => '7',
'clicked_through_count' => '6',
],
]);
$this->assertSame(
[
'6' => [
'sent_count' => 12,
'read_count' => 6,
'clicked_count' => 3,
'open_rate' => 0.5,
'click_through_rate' => 0.25,
'click_through_open_rate' => 0.5,
],
'8' => [
'sent_count' => 13,
'read_count' => 7,
'clicked_count' => 6,
'open_rate' => 0.5385,
'click_through_rate' => 0.4615,
'click_through_open_rate' => 0.8571,
],
],
$this->statRepository->getStatsSummaryForContacts([6, 8])
);
}
public function testGetReadCount(): void
{
$expectedQuery = 'SELECT count(s.id) as count FROM test_email_stats s WHERE (s.email_id IN (1)) AND (is_read = :true) AND (s.date_read BETWEEN :dateFrom AND :dateTo)';
$this->connection->expects($this->once())
->method('executeQuery')
->with(
$expectedQuery,
[
'true' => true,
'dateFrom' => '2023-01-01 00:00:00',
'dateTo' => '2023-01-31 23:59:59',
]
)
->willReturn($this->result);
$this->result->method('fetchAllAssociative')
->willReturn([
[
'count' => 1,
],
]);
$query = new ChartQuery($this->connection, new \DateTime('2023-01-01'), new \DateTime('2023-01-31'));
$this->assertSame(1, $this->statRepository->getReadCount(1, null, $query));
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Tests\Entity;
use Mautic\EmailBundle\Entity\EmailReply;
use Mautic\EmailBundle\Entity\Stat;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
class StatTest extends TestCase
{
/**
* @param int $count How many openDetails to add to the entity
*/
#[\PHPUnit\Framework\Attributes\DataProvider('addOpenDetailsTestProvider')]
public function testAddOpenDetails(int $count): void
{
$stat = new Stat();
// Add as many openDetails entries as specified in $count
for ($i = 0; $i < $count; ++$i) {
$stat->addOpenDetails(sprintf('Open %d of %d', $i + 1, $count));
}
// Assert that the openCount reflects the total number of openDetails
$this->assertEquals($count, $stat->getOpenCount());
// Assert that the number of entries stored in the openDetails array
// is equal to the lower of the two values openCount and
// Stat::MAX_OPEN_DETAILS
$this->assertEquals(
min(Stat::MAX_OPEN_DETAILS, $stat->getOpenCount()),
count($stat->getOpenDetails())
);
}
/**
* Data provider for addOpenDetails.
*/
public static function addOpenDetailsTestProvider(): array
{
return [
'no openDetails' => [0],
'one openDetail' => [1],
'low number of openDetails' => [10],
'one away from threshold' => [Stat::MAX_OPEN_DETAILS - 1],
'exactly at threshold' => [Stat::MAX_OPEN_DETAILS],
'one past threshold' => [Stat::MAX_OPEN_DETAILS + 1],
'slightly above threshold' => [Stat::MAX_OPEN_DETAILS + 10],
'well beyond threshold' => [Stat::MAX_OPEN_DETAILS * 10],
];
}
public function testChanges(): void
{
$stat = new Stat();
$stat->setEmailAddress('john@doe.email');
$stat->setIsFailed(true);
$stat->setDateRead(new \DateTime());
$stat->setDateSent(new \DateTime());
$stat->setLastOpened(new \DateTime());
$stat->setIsRead(false);
$stat->setOpenCount(2);
$stat->setRetryCount(3);
$stat->setSource('campaign');
$stat->setSourceId(123);
$stat->addReply(new EmailReply($stat, '456'));
Assert::assertSame([null, 'john@doe.email'], $stat->getChanges()['emailAddress']);
Assert::assertSame([false, true], $stat->getChanges()['isFailed']);
Assert::assertSame([0, 2], $stat->getChanges()['openCount']);
Assert::assertSame([0, 3], $stat->getChanges()['retryCount']);
Assert::assertSame([null, 'campaign'], $stat->getChanges()['source']);
Assert::assertSame([null, 123], $stat->getChanges()['sourceId']);
Assert::assertSame([false, true], $stat->getChanges()['replyAdded']);
Assert::assertArrayNotHasKey('isRead', $stat->getChanges()); // Don't want to record changes from false to false.
Assert::assertNull($stat->getChanges()['dateRead'][0]);
Assert::assertInstanceOf(\DateTime::class, $stat->getChanges()['dateRead'][1]);
Assert::assertNull($stat->getChanges()['dateSent'][0]);
Assert::assertInstanceOf(\DateTime::class, $stat->getChanges()['dateSent'][1]);
Assert::assertNull($stat->getChanges()['lastOpened'][0]);
Assert::assertInstanceOf(\DateTime::class, $stat->getChanges()['lastOpened'][1]);
$stat->upOpenCount();
$stat->upRetryCount();
$stat->setEmailAddress('john@doe.email');
$stat->setDateRead(new \DateTime());
$stat->setIsRead(true);
$stat->setSource('campaign');
$stat->setSourceId(321);
$stat->addReply(new EmailReply($stat, '456'));
Assert::assertSame([null, 'john@doe.email'], $stat->getChanges()['emailAddress']);
Assert::assertSame([false, true], $stat->getChanges()['isFailed']);
Assert::assertSame([2, 3], $stat->getChanges()['openCount']);
Assert::assertSame([3, 4], $stat->getChanges()['retryCount']);
Assert::assertSame([null, 'campaign'], $stat->getChanges()['source']);
Assert::assertSame([123, 321], $stat->getChanges()['sourceId']);
Assert::assertSame([false, true], $stat->getChanges()['replyAdded']);
Assert::assertSame([false, true], $stat->getChanges()['isRead']);
Assert::assertInstanceOf(\DateTime::class, $stat->getChanges()['dateRead'][0]);
Assert::assertInstanceOf(\DateTime::class, $stat->getChanges()['dateRead'][1]);
Assert::assertNull($stat->getChanges()['dateSent'][0]);
Assert::assertInstanceOf(\DateTime::class, $stat->getChanges()['dateSent'][1]);
}
}