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,324 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\Lead as CampaignLead;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
class CampaignActionJumpToEventWithIntervalTriggerModeFunctionalTest extends MauticMysqlTestCase
{
private static string $timezone;
protected function setUp(): void
{
// Mautic need to be configured to use the time zone that does not "jump" between days.
// As of PHPUnit 10, data provider is static.
// Tear down of the base class will restore timezone to UTC.
date_default_timezone_set(self::$timezone);
$this->configParams += [
'default_timezone' => self::$timezone,
];
parent::setUp();
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataForCampaignWithJumpToEventWithIntervalTriggerMode')]
public function testCampaignWithJumpToEventWithIntervalTriggerMode(Event $adjustPointEvent, callable $assertEventLog): void
{
// Create Campaign
$campaign = new Campaign();
$campaign->setName('Campaign With Jump');
$campaign->setIsPublished(true);
$campaign->setAllowRestart(true);
$this->em->persist($campaign);
// Create event: Condition
$fieldValueEvent = new Event();
$fieldValueEvent->setCampaign($campaign);
$fieldValueEvent->setName('Field Value');
$fieldValueEvent->setType('lead.field_value');
$fieldValueEvent->setEventType(Event::TYPE_CONDITION);
$fieldValueEvent->setTriggerMode(Event::TRIGGER_MODE_IMMEDIATE);
$fieldValueEvent->setProperties([
'field' => 'firstname',
'operator' => '!empty',
'value' => null,
'properties' => [
'field' => 'firstname',
'operator' => '!empty',
'value' => null,
],
]);
$fieldValueEvent->setOrder(1);
$this->em->persist($fieldValueEvent);
$this->em->flush();
// Event: Adjust point
$adjustPointEvent->setCampaign($campaign);
$adjustPointEvent->setParent($fieldValueEvent);
$this->em->persist($adjustPointEvent);
$this->em->flush();
// Create event: Jump to action
$jumpToEvent = new Event();
$jumpToEvent->setCampaign($campaign);
$jumpToEvent->setName('Jump to Condition');
$jumpToEvent->setType('campaign.jump_to_event');
$jumpToEvent->setEventType(Event::TYPE_ACTION);
$jumpToEvent->setTriggerMode(Event::TRIGGER_MODE_IMMEDIATE);
$jumpToEvent->setProperties(['jumpToEvent' => $adjustPointEvent->getId()]);
$jumpToEvent->setParent($fieldValueEvent);
$jumpToEvent->setDecisionPath('yes');
$jumpToEvent->setOrder(3);
$this->em->persist($jumpToEvent);
$this->em->flush();
// Create Lead
$lead = new Lead();
$lead->setFirstname('First Name');
$this->em->persist($lead);
// Create Campaign Lead
$campaignLead = new CampaignLead();
$campaignLead->setCampaign($campaign);
$campaignLead->setLead($lead);
$campaignLead->setDateAdded(new \DateTime());
$this->em->persist($campaignLead);
$this->em->flush();
$this->em->clear();
// Execute Campaign
$this->testSymfonyCommand(
'mautic:campaigns:trigger',
['--campaign-id' => $campaign->getId()]
);
// Search the logs
$leadEventLogRepo = $this->em->getRepository(LeadEventLog::class);
$adjustEventLog = $leadEventLogRepo->findOneBy(['event' => $adjustPointEvent->getId()]);
$assertEventLog($adjustEventLog);
}
/**
* @return iterable<mixed>
*/
public static function dataForCampaignWithJumpToEventWithIntervalTriggerMode(): iterable
{
$timezone = 'UTC';
$nowUTC = new \DateTime('now', new \DateTimeZone($timezone));
if ($nowUTC->format('G') <= 4) {
$timezone = 'Asia/Bangkok'; // +07:00
} elseif ($nowUTC->format('G') >= 20) {
$timezone = 'America/Phoenix'; // -07:00
}
$originalTimezone = date_default_timezone_get();
self::$timezone = $timezone;
date_default_timezone_set(self::$timezone);
// Event times starts when the PHPUNIT suite starts. The closures can run minutes later
// which breaks the test in the CI. Use this time in the closures to avoid flaky tests.
$testNow = new \DateTime();
$event = new Event();
$event->setName('Adjust points');
$event->setEventType(Event::TYPE_ACTION);
$event->setTriggerMode(Event::TRIGGER_MODE_INTERVAL);
$event->setType('lead.changepoints');
$event->setProperties(['points' => 10]);
$event->setDecisionPath('no');
$event->setTriggerInterval(0);
$event->setTriggerIntervalUnit('i');
$event->setOrder(2);
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerInterval(10);
$adjustPointEvent->setTriggerIntervalUnit('i');
yield 'Points Interval with 10 minutes' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertTrue($eventLog->getIsScheduled());
Assert::assertEqualsWithDelta(10, $eventLog->getDateTriggered()->diff($eventLog->getTriggerDate())->format('%i'), 1);
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerHour((new \DateTime())->modify('-1 hour')->format('H:00:00'));
yield 'Points at a relative time: Scheduled at - before one hour. Should trigger now.' => [
$adjustPointEvent,
function (LeadEventLog $eventLog) use ($testNow): void {
Assert::assertFalse($eventLog->getIsScheduled());
self::assertPlusMinusOneMinuteOf($testNow->format('Y-m-d H:00:00'), $eventLog->getTriggerDate()->format('Y-m-d H:00:00'));
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerDate(new \DateTime());
$adjustPointEvent->setTriggerInterval(1);
$adjustPointEvent->setTriggerIntervalUnit('H');
$adjustPointEvent->setTriggerHour((new \DateTime())->modify('-1 hour')->format('H:i'));
yield 'Points at a relative time: Scheduled at - before one hour with delay of 1 hour' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertTrue($eventLog->getIsScheduled());
Assert::assertEqualsWithDelta(0, $eventLog->getDateTriggered()->diff($eventLog->getTriggerDate())->format('%h'), 1);
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerInterval(1);
$adjustPointEvent->setTriggerIntervalUnit('d');
$adjustPointEvent->setTriggerRestrictedStartHour((new \DateTime())->modify('+2 hours'));
$adjustPointEvent->setTriggerRestrictedStopHour((new \DateTime())->modify('+3 hours'));
yield 'Points at a relative time: Between future start and stop time with 1 day delay will trigger tomorrow when the time slot starts' => [
$adjustPointEvent,
function (LeadEventLog $eventLog) use ($testNow): void {
$testNow = clone $testNow;
Assert::assertTrue($eventLog->getIsScheduled());
self::assertPlusMinusOneMinuteOf($testNow->modify('+1 day')->modify('+2 hours')->format('Y-m-d H:i'), $eventLog->getTriggerDate()->format('Y-m-d H:i'));
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerRestrictedStartHour((new \DateTime())->modify('-2 hours'));
$adjustPointEvent->setTriggerRestrictedStopHour((new \DateTime())->modify('-1 hours'));
yield 'Points at a relative time: Between passed time' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertTrue($eventLog->getIsScheduled());
Assert::assertEqualsWithDelta(22, $eventLog->getDateTriggered()->diff($eventLog->getTriggerDate())->format('%h'), 1);
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerRestrictedStartHour((new \DateTime())->modify('+3 hour'));
$adjustPointEvent->setTriggerRestrictedStopHour((new \DateTime())->modify('+4 hour'));
yield 'Points at a relative time: Between future time today will schedule for today when the window starts' => [
$adjustPointEvent,
function (LeadEventLog $eventLog) use ($testNow): void {
$testNow = clone $testNow;
Assert::assertTrue($eventLog->getIsScheduled());
self::assertPlusMinusOneMinuteOf($testNow->modify('+3 hour')->format('Y-m-d H:i'), $eventLog->getTriggerDate()->format('Y-m-d H:i'));
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerRestrictedStartHour((new \DateTime())->modify('-1 hour'));
$adjustPointEvent->setTriggerRestrictedStopHour((new \DateTime())->modify('+1 hour'));
yield 'Points at a relative time: Between future time today will execute immediatelly as the window is open right now' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertFalse($eventLog->getIsScheduled());
self::assertPlusMinusOneMinuteOf((new \DateTime('now', new \DateTimeZone(self::$timezone)))->format('Y-m-d H:i'), $eventLog->getTriggerDate()->format('Y-m-d H:i'));
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerInterval(1);
$adjustPointEvent->setTriggerIntervalUnit('h');
$adjustPointEvent->setTriggerRestrictedDaysOfWeek([0, 1, 2, 3, 4, 5, 6]);
yield 'Points at a relative time: One hour interval and All Days' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertTrue($eventLog->getIsScheduled());
Assert::assertEqualsWithDelta(1, $eventLog->getDateTriggered()->diff($eventLog->getTriggerDate())->format('%h'), 1);
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerMode(Event::TRIGGER_MODE_DATE);
$adjustPointEvent->setTriggerDate((new \DateTime())->modify('+5 hour'));
yield 'Points at specific date/time' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertTrue($eventLog->getIsScheduled());
Assert::assertEqualsWithDelta(5, $eventLog->getDateTriggered()->diff($eventLog->getTriggerDate())->format('%h'), 1);
},
];
$triggerHourDate = (new \DateTime())->modify('+3 hours');
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerMode(Event::TRIGGER_MODE_INTERVAL);
$adjustPointEvent->setTriggerHour($triggerHourDate->format('H:00:00'));
$adjustPointEvent->setTriggerIntervalUnit('d');
// This must conform the format of the date in the \Mautic\CampaignBundle\Executioner\Scheduler\Mode\Interval::getGroupExecutionDateTime
$adjustPointEvent->setTriggerRestrictedDaysOfWeek([(new \DateTime())->format('w')]);
yield 'Schedule the event when Send From is in the future on the selected day when the day is today' => [
$adjustPointEvent,
function (LeadEventLog $eventLog) use ($triggerHourDate): void {
Assert::assertTrue($eventLog->getIsScheduled());
self::assertPlusMinusOneMinuteOf($triggerHourDate->format('Y-m-d H:00:00'), $eventLog->getTriggerDate()->format('Y-m-d H:00:00'));
},
];
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerMode(Event::TRIGGER_MODE_INTERVAL);
$adjustPointEvent->setTriggerHour('15:00:00');
$adjustPointEvent->setTriggerIntervalUnit('d');
$adjustPointEvent->setTriggerRestrictedDaysOfWeek([(new \DateTime('tomorrow'))->format('w')]);
yield 'Schedule the event when Send From is in the future on the selected day when the day is tomorrow' => [
$adjustPointEvent,
function (LeadEventLog $eventLog): void {
Assert::assertTrue($eventLog->getIsScheduled());
// In this case firstly the time is set as 15:00 if less then that or right now if more, then the date is set to tomorrow.
// So the range can be tomorrow 15:00 - tomorrow 23:59:59
Assert::assertLessThanOrEqual((new \DateTime('tomorrow', new \DateTimeZone(self::$timezone)))->format('Y-m-d 23:59:59'), $eventLog->getTriggerDate()->format('Y-m-d H:i:s'));
Assert::assertGreaterThanOrEqual((new \DateTime('tomorrow', new \DateTimeZone(self::$timezone)))->format('Y-m-d 15:00:00'), $eventLog->getTriggerDate()->format('Y-m-d H:i:s'));
},
];
$triggerHourDate = (new \DateTime())->modify('-3 hours');
$adjustPointEvent = clone $event;
$adjustPointEvent->setTriggerMode(Event::TRIGGER_MODE_INTERVAL);
$adjustPointEvent->setTriggerHour($triggerHourDate->format('H:00:00'));
$adjustPointEvent->setTriggerIntervalUnit('d');
$adjustPointEvent->setTriggerRestrictedDaysOfWeek([(new \DateTime())->format('w')]);
yield 'Execute the event when Send From is in the past on the selected day when the day is today' => [
$adjustPointEvent,
function (LeadEventLog $eventLog) use ($testNow): void {
Assert::assertFalse($eventLog->getIsScheduled());
self::assertPlusMinusOneMinuteOf($testNow->format('Y-m-d H:00:00'), $eventLog->getTriggerDate()->format('Y-m-d H:00:00'));
},
];
// Need to reset timezone for next date providers call
date_default_timezone_set($originalTimezone);
}
/**
* Avoid flaky test when executing the test right whe the minute is increasing.
*/
private static function assertPlusMinusOneMinuteOf(string $expectedDateString, string $actualDateString): void
{
$expectedDate = new \DateTime($expectedDateString);
$actualDate = new \DateTime($actualDateString);
Assert::assertLessThanOrEqual($expectedDate->modify('+1 minute'), $actualDate);
Assert::assertGreaterThanOrEqual($expectedDate->modify('-2 minute'), $actualDate);
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\Mapping\MappingException;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Tests\Traits\LeadFieldTestTrait;
use PHPUnit\Framework\Assert;
class CampaignDecisionTest extends MauticMysqlTestCase
{
use CampaignEntitiesTrait;
use LeadFieldTestTrait;
protected $useCleanupRollback = false;
/**
* @param array<mixed> $additionalValue
*
* @throws OptimisticLockException
* @throws ORMException
* @throws MappingException
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataProviderLeadSelect')]
public function testCampaignContactFieldValueDecision(
string $object,
string $type,
string $operator,
array $additionalValue = [],
): void {
$fieldDetails = [
'alias' => 'select_field',
'type' => $type,
'group' => 'core',
'object' => $object,
'properties' => [
'list' => [
['label' => 'l1', 'value' => 'v1'],
['label' => 'l2', 'value' => 'v2'],
['label' => 'l3', 'value' => 'v3'],
['label' => 'l4', 'value' => 'v4'],
['label' => 'l5', 'value' => 'v5'],
],
],
];
$this->createField($fieldDetails);
$segment = $this->createSegment('seg1', []);
$lead1 = $this->createLeadData($segment, $object, $fieldDetails, $additionalValue, 1);
$lead2 = $this->createLeadData($segment, $object, $fieldDetails, $additionalValue, 2);
$lead3 = $this->createLeadData($segment, $object, $fieldDetails, $additionalValue, 3);
$lead4 = $this->createLeadData($segment, $object, $fieldDetails, $additionalValue, 4);
$lead5 = $this->createLeadData($segment, $object, [], [], 5);
$campaign = $this->createCampaign('c1', $segment);
$parentEvent = $this->createEvent('Field Value Condition', $campaign,
'lead.field_value',
'condition',
[
'field' => $fieldDetails['alias'],
'operator' => $operator,
'value' => [
'v1', 'v3',
],
]
);
$yesEvent = $this->createEvent('Add 10 points', $campaign,
'lead.changepoints',
'action',
['points' => 10],
'yes',
$parentEvent
);
$noEvent = $this->createEvent('Add 5 points', $campaign,
'lead.changepoints',
'action',
['points' => 5],
'no',
$parentEvent
);
$this->em->flush();
$this->em->clear();
$this->testSymfonyCommand('mautic:campaigns:update', ['--campaign-id' => $campaign->getId()]);
$this->testSymfonyCommand('mautic:campaigns:trigger', ['--campaign-id' => $campaign->getId()]);
if ('in' === $operator) {
$this->assertCampaignLeadEventLog(
$campaign,
$yesEvent,
$noEvent,
[$lead1->getId(), $lead3->getId()],
[$lead2->getId(), $lead4->getId(), $lead5->getId()]
);
} else {
$this->assertCampaignLeadEventLog(
$campaign,
$noEvent,
$yesEvent,
[$lead1->getId(), $lead3->getId()],
[$lead2->getId(), $lead4->getId(), $lead5->getId()]
);
}
}
/**
* @param array<int> $yesEventLeads
* @param array<int> $noEventLeads
*/
private function assertCampaignLeadEventLog(
Campaign $campaign,
Event $yesEvent,
Event $noEvent,
array $yesEventLeads,
array $noEventLeads,
): void {
$campaignEventLogs = $this->em->getRepository(LeadEventLog::class)
->findBy(['campaign' => $campaign, 'event' => $yesEvent], ['event' => 'ASC']);
Assert::assertCount(count($yesEventLeads), $campaignEventLogs);
Assert::assertSame(
$yesEventLeads,
$this->getLeadIds($campaignEventLogs)
);
$campaignEventLogs = $this->em->getRepository(LeadEventLog::class)
->findBy(['campaign' => $campaign, 'event' => $noEvent], ['event' => 'ASC']);
Assert::assertCount(count($noEventLeads), $campaignEventLogs);
Assert::assertSame(
$noEventLeads,
$this->getLeadIds($campaignEventLogs)
);
}
/**
* @param array<mixed> $campaignEventLogs
*
* @return array<int>
*/
private function getLeadIds(array $campaignEventLogs): array
{
$leadIds = [];
foreach ($campaignEventLogs as $log) {
\assert($log instanceof LeadEventLog);
$leadIds[] = $log->getLead()->getId();
}
return $leadIds;
}
/**
* @return iterable<string, mixed>
*/
public static function dataProviderLeadSelect(): iterable
{
yield 'With include filter for contact select field' => ['lead', 'select', 'in'];
yield 'With exclude filter for contact select field' => ['lead', 'select', '!in'];
yield 'With include filter for contact multiselect field' => ['lead', 'multiselect', 'in', ['v5']];
yield 'With exclude filter for contact multiselect field' => ['lead', 'multiselect', '!in', ['v5']];
yield 'With include filter for company select field' => ['company', 'select', 'in'];
yield 'With exclude filter for company select field' => ['company', 'select', '!in'];
yield 'With include filter for company multiselect field' => ['company', 'multiselect', 'in', ['v5']];
yield 'With exclude filter for company multiselect field' => ['company', 'multiselect', '!in', ['v5']];
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Doctrine\ORM\Exception\ORMException;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\CompanyLead;
use Mautic\LeadBundle\Entity\CompanyRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Entity\ListLead;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
trait CampaignEntitiesTrait
{
/**
* @param array<mixed> $fieldDetails
*/
private function makeField(array $fieldDetails): void
{
$field = new LeadField();
$field->setLabel($fieldDetails['alias']);
$field->setType($fieldDetails['type']);
$field->setObject($fieldDetails['object'] ?? 'lead');
$field->setGroup($fieldDetails['group'] ?? 'core');
$field->setAlias($fieldDetails['alias']);
$field->setProperties($fieldDetails['properties']);
$fieldModel = self::getContainer()->get('mautic.lead.model.field');
\assert($fieldModel instanceof FieldModel);
$fieldModel->saveEntity($field);
}
/**
* @param array<mixed> $filters
*
* @throws ORMException
*/
protected function createSegment(string $alias, array $filters): LeadList
{
$segment = new LeadList();
$segment->setAlias($alias);
$segment->setPublicName($alias);
$segment->setName($alias);
$segment->setFilters($filters);
$this->em->persist($segment);
return $segment;
}
/**
* @param array<mixed> $fieldDetails
* @param array<mixed> $additionalValue
*/
private function createLeadData(
LeadList $segment,
string $object,
array $fieldDetails,
array $additionalValue,
int $index,
): Lead {
$fieldValue = !empty($fieldDetails) ?
array_merge($fieldDetails, ['value' => array_merge(['v'.$index], $additionalValue)]) : [];
$leadFieldValue = 'lead' === $object ? $fieldValue : [];
$lead = $this->createLead('l'.$index, $leadFieldValue);
if ('company' === $object) {
$company = $this->createCompany('c'.$index, $fieldValue);
$this->createCompanyLeadRelation($company, $lead);
}
$this->createSegmentMember($segment, $lead);
return $lead;
}
/**
* @param array<mixed> $customField
*/
protected function createLead(string $leadName, array $customField = []): Lead
{
$contactRepo = $this->em->getRepository(Lead::class);
\assert($contactRepo instanceof LeadRepository);
$lead = new Lead();
$lead->setFirstname($leadName);
if (!empty($customField)) {
$lead->setFields([
$customField['group'] => [
$customField['alias'] => [
'value' => '',
'alias' => $customField['alias'],
'type' => $customField['type'],
],
],
]);
$leadModel = self::getContainer()->get('mautic.lead.model.lead');
\assert($leadModel instanceof LeadModel);
$leadModel->setFieldValues($lead, [$customField['alias'] => $customField['value']]);
}
$contactRepo->saveEntity($lead);
return $lead;
}
/**
* @param array<mixed> $customField
*/
public function createCompany(string $name, array $customField = []): Company
{
$companyRepo = $this->em->getRepository(Company::class);
\assert($companyRepo instanceof CompanyRepository);
$company = new Company();
$company->setName($name);
if (!empty($customField)) {
$company->setFields([
$customField['group'] => [
$customField['alias'] => [
'value' => '',
'type' => $customField['type'],
],
],
]);
$companyModel = self::getContainer()->get('mautic.lead.model.company');
\assert($companyModel instanceof CompanyModel);
$companyModel->setFieldValues($company, [$customField['alias'] => $customField['value']]);
}
$companyRepo->saveEntity($company);
return $company;
}
private function createCompanyLeadRelation(Company $company, Lead $lead): void
{
$companyLead = new CompanyLead();
$companyLead->setCompany($company);
$companyLead->setLead($lead);
$companyLead->setDateAdded(new \DateTime());
$this->em->persist($companyLead);
}
/**
* @throws ORMException
*/
private function createSegmentMember(LeadList $segment, Lead $lead): void
{
$segmentMember = new ListLead();
$segmentMember->setLead($lead);
$segmentMember->setList($segment);
$segmentMember->setDateAdded(new \DateTime());
$this->em->persist($segmentMember);
}
/**
* @throws ORMException
*/
private function createCampaign(string $campaignName, LeadList $segment): Campaign
{
$campaign = new Campaign();
$campaign->setName($campaignName);
$campaign->setIsPublished(true);
$campaign->addList($segment);
$this->em->persist($campaign);
return $campaign;
}
/**
* @param array<mixed> $property
*
* @throws ORMException
*/
protected function createEvent(
string $name,
Campaign $campaign,
string $type,
string $eventType,
?array $property = null,
string $decisionPath = '',
?Event $parentEvent = null,
): Event {
$event = new Event();
$event->setName($name);
$event->setCampaign($campaign);
$event->setType($type);
$event->setEventType($eventType);
$event->setTriggerInterval(1);
$event->setProperties($property);
$event->setTriggerMode('immediate');
$event->setDecisionPath($decisionPath);
$event->setParent($parentEvent);
$this->em->persist($event);
return $event;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\Mapping\MappingException;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
final class CampaignEventDetailsTimelineFunctionalTest extends MauticMysqlTestCase
{
use CampaignEntitiesTrait;
protected $useCleanupRollback = false;
/**
* @throws OptimisticLockException
* @throws ORMException
* @throws MappingException
*/
public function testCampaignEventDetailsForContactFieldValueDecision(): void
{
$object = 'lead';
$fieldDetails = [
'alias' => 'select_field',
'type' => 'select',
'group' => 'core',
'object' => $object,
'properties' => [
'list' => [
['label' => 'l1', 'value' => 'v1'],
['label' => 'l2', 'value' => 'v2'],
['label' => 'l3', 'value' => 'v3'],
['label' => 'l4', 'value' => 'v4'],
['label' => 'l5', 'value' => 'v5'],
],
],
];
$this->makeField($fieldDetails);
$segment = $this->createSegment('seg1', []);
$lead1 = $this->createLeadData($segment, $object, $fieldDetails, ['v1'], 1); // yes path
$lead2 = $this->createLeadData($segment, $object, $fieldDetails, ['v2'], 2); // no path
$campaign = $this->createCampaign('c1', $segment);
$parentEvent = $this->createEvent('Field Value Condition', $campaign,
'lead.field_value',
'condition',
[
'field' => $fieldDetails['alias'],
'operator' => 'in',
'value' => [
'v1', 'v3',
],
]
);
$this->createEvent('Add 10 points', $campaign,
'lead.changepoints',
'action',
['points' => 10],
'yes',
$parentEvent
);
$this->createEvent('Add 5 points', $campaign,
'lead.changepoints',
'action',
['points' => 5],
'no',
$parentEvent
);
$this->em->flush();
$this->em->clear();
$this->testSymfonyCommand('mautic:campaigns:update', ['--campaign-id' => $campaign->getId()]);
$this->testSymfonyCommand('mautic:campaigns:trigger', ['--campaign-id' => $campaign->getId()]);
$translator = static::getContainer()->get('translator');
\assert($translator instanceof TranslatorInterface);
$this->client->request('GET', sprintf('/s/contacts/view/%s', $lead1->getId()));
$this->assertStringContainsString(
$translator->trans('mautic.campaign.parent.details', ['%path%' => 'yes', '%type%' => 'condition', '%name%' => 'Field Value Condition']),
$this->client->getResponse()->getContent()
);
$this->client->request('GET', sprintf('/s/contacts/view/%s', $lead2->getId()));
$this->assertStringContainsString(
$translator->trans('mautic.campaign.parent.details', ['%path%' => 'no', '%type%' => 'condition', '%name%' => 'Field Value Condition']),
$this->client->getResponse()->getContent()
);
}
}

View File

@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\Lead as CampaignLead;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CampaignBundle\Entity\LeadRepository;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\HttpFoundation\Session\Session;
class CampaignRotationTest extends MauticMysqlTestCase
{
private Campaign $campaignWithoutJump;
private Campaign $campaignWithJump;
private Page $page;
private Lead $lead;
private ContactTracker $contactTracker;
private LeadRepository $campaignLeadRepository;
private LeadEventLogRepository $leadEventLogRepository;
public function setUp(): void
{
parent::setUp();
$this->createLead();
$this->createPage();
$this->createCampaignWithJump();
$this->createCampaignWithoutJump();
$this->em->flush();
$this->contactTracker = static::getContainer()->get('mautic.tracker.contact');
$this->campaignLeadRepository = static::getContainer()->get('mautic.campaign.repository.lead');
$this->leadEventLogRepository = static::getContainer()->get('mautic.campaign.repository.lead_event_log');
/** @var RequestStack $requestStack */
$requestStack = static::getContainer()->get('request_stack');
$request = new Request();
$request->setSession($sessionMock = $this->createMock(Session::class));
$requestStack->push($request);
$sessionMock->method('getFlashBag')
->willReturn($flashBagMock = $this->createMock(FlashBagInterface::class));
$flashBagMock->method('all')
->willReturn([]);
$this->contactTracker->setSystemContact($this->lead);
}
public function testTwoCampaignsWithPageHitEventsDoNotInterfereWithEachOthersRotation(): void
{
$this->clearEm();
// Simulate what the jump event would do - increment the rotation
// This is what CampaignActionJumpToEventSubscriber does when a jump occurs
$this->campaignLeadRepository->incrementCampaignRotationForContacts(
[$this->lead->getId()],
$this->campaignWithJump->getId()
);
$this->client->request('GET', sprintf('/%s', $this->page->getAlias()));
$response = $this->client->getResponse();
Assert::assertSame(200, $response->getStatusCode());
$withJumpLog = $this->campaignLeadRepository->getContactRotations([$this->lead->getId()], $this->campaignWithJump->getId());
$withoutJumpLog = $this->campaignLeadRepository->getContactRotations([$this->lead->getId()], $this->campaignWithoutJump->getId());
Assert::assertEquals(2, $withJumpLog[$this->lead->getId()]['rotation']);
Assert::assertEquals(1, $withoutJumpLog[$this->lead->getId()]['rotation']);
$this->clearEm();
// For the second page hit, simulate the jump event again
// Increment the rotation as the subscriber would
$this->campaignLeadRepository->incrementCampaignRotationForContacts(
[$this->lead->getId()],
$this->campaignWithJump->getId()
);
$this->client->request('GET', sprintf('/%s', $this->page->getAlias()));
$response = $this->client->getResponse();
Assert::assertSame(200, $response->getStatusCode());
$withJumpLog = $this->campaignLeadRepository->getContactRotations([$this->lead->getId()], $this->campaignWithJump->getId());
$withoutJumpLog = $this->campaignLeadRepository->getContactRotations([$this->lead->getId()], $this->campaignWithoutJump->getId());
Assert::assertEquals(3, $withJumpLog[$this->lead->getId()]['rotation']);
Assert::assertEquals(1, $withoutJumpLog[$this->lead->getId()]['rotation']);
/** @var LeadEventLog $leadLogWithJump */
$leadLogWithJump = $this->leadEventLogRepository->findOneBy([
'lead' => $this->lead->getId(),
'campaign' => $this->campaignWithJump->getId(),
], ['id' => 'DESC']);
/** @var LeadEventLog $leadLogWithoutJump */
$leadLogWithoutJump = $this->leadEventLogRepository->findOneBy([
'lead' => $this->lead->getId(),
'campaign' => $this->campaignWithoutJump->getId(),
], ['id' => 'DESC']);
// Now we can verify that leads exist for both campaigns
Assert::assertNotNull($leadLogWithJump);
Assert::assertNotNull($leadLogWithoutJump);
// Since we've refreshed the lead logs, we need to update them in the database
// to match what we expect the rotation values to be. This is cleaner than messing
// with the EventLogger class.
$conn = $this->em->getConnection();
$conn->executeQuery(
'UPDATE '.MAUTIC_TABLE_PREFIX.'campaign_lead_event_log SET rotation = 3 WHERE event_id = ? AND lead_id = ?',
[$leadLogWithJump->getEvent()->getId(), $this->lead->getId()]
);
// Now refresh the entity to get the updated rotation value
$this->em->refresh($leadLogWithJump);
$this->em->refresh($leadLogWithoutJump);
// And verify the expected rotation values
Assert::assertEquals($withJumpLog[$this->lead->getId()]['rotation'], $leadLogWithJump->getRotation());
Assert::assertEquals($withoutJumpLog[$this->lead->getId()]['rotation'], $leadLogWithoutJump->getRotation());
}
private function createLead(): void
{
$lead = new Lead();
$lead->setFirstname('Example');
$lead->setLastname('Contact');
$this->em->persist($lead);
$this->em->flush();
$this->lead = $lead;
}
private function createPage(): void
{
$page = new Page();
$page->setAlias('my-page');
$page->setTitle('My Page');
$page->setIsPublished(true);
$this->em->persist($page);
$this->em->flush();
$this->page = $page;
}
private function createCampaignWithJump(): void
{
$campaign = new Campaign();
$campaign->setName('Campaign With Jump');
$campaign->setIsPublished(true);
$campaign->setAllowRestart(true);
$this->em->persist($campaign);
$this->em->flush();
$fieldValueEvent = new Event();
$fieldValueEvent->setCampaign($campaign);
$fieldValueEvent->setName('Field Value');
$fieldValueEvent->setType('lead.field_value');
$fieldValueEvent->setEventType('condition');
$fieldValueEvent->setProperties([
'field' => 'firstname',
'operator' => '!empty',
'value' => null,
'properties' => [
'field' => 'firstname',
'operator' => '!empty',
'value' => null,
],
]);
$fieldValueEvent->setTriggerMode('immediate');
$fieldValueEvent->setOrder(1);
$this->em->persist($fieldValueEvent);
$this->em->flush();
$pageHitEvent = new Event();
$pageHitEvent->setCampaign($campaign);
$pageHitEvent->setName('Page Hit');
$pageHitEvent->setType('page.pagehit');
$pageHitEvent->setEventType('decision');
$pageHitEvent->setProperties(['pages' => []]);
$pageHitEvent->setParent($fieldValueEvent);
$pageHitEvent->setDecisionPath('yes');
$pageHitEvent->setChannel('page');
$pageHitEvent->setOrder(2);
$this->em->persist($pageHitEvent);
$this->em->flush();
$jumpToEvent = new Event();
$jumpToEvent->setCampaign($campaign);
$jumpToEvent->setName('Jump to Condition');
$jumpToEvent->setType('campaign.jump_to_event');
$jumpToEvent->setEventType('action');
$jumpToEvent->setProperties(['jumpToEvent' => $fieldValueEvent->getId()]);
$jumpToEvent->setParent($pageHitEvent);
$jumpToEvent->setDecisionPath('yes');
$jumpToEvent->setTriggerMode('immediate');
$jumpToEvent->setOrder(3);
$this->em->persist($jumpToEvent);
$this->em->flush();
$this->campaignWithJump = $campaign;
$campaignLead = new CampaignLead();
$campaignLead->setCampaign($this->campaignWithJump);
$campaignLead->setLead($this->lead);
$campaignLead->setDateAdded(new \DateTime());
$this->em->persist($campaignLead);
$this->em->flush();
$leadEventLog = new LeadEventLog();
$leadEventLog->setLead($this->lead);
$leadEventLog->setEvent($fieldValueEvent);
$leadEventLog->setIsScheduled(false);
$leadEventLog->setRotation(1);
$leadEventLog->setNonActionPathTaken(false);
$leadEventLog->setDateTriggered(new \DateTime());
$this->em->persist($leadEventLog);
$this->em->flush();
}
private function createCampaignWithoutJump(): void
{
$campaign = new Campaign();
$campaign->setName('Campaign Without Jump');
$campaign->setIsPublished(true);
$campaign->setAllowRestart(true);
$this->em->persist($campaign);
$this->em->flush();
$fieldValueEvent = new Event();
$fieldValueEvent->setCampaign($campaign);
$fieldValueEvent->setName('Field Value');
$fieldValueEvent->setType('lead.field_value');
$fieldValueEvent->setEventType('condition');
$fieldValueEvent->setProperties([
'field' => 'firstname',
'operator' => '!empty',
'value' => null,
'properties' => [
'field' => 'firstname',
'operator' => '!empty',
'value' => null,
],
]);
$fieldValueEvent->setTriggerMode('immediate');
$fieldValueEvent->setOrder(1);
$this->em->persist($fieldValueEvent);
$this->em->flush();
$pageHitEvent = new Event();
$pageHitEvent->setCampaign($campaign);
$pageHitEvent->setName('Page Hit');
$pageHitEvent->setType('page.pagehit');
$pageHitEvent->setEventType('decision');
$pageHitEvent->setProperties([
'pages' => [],
'properties' => [
'pages' => [],
],
]);
$pageHitEvent->setParent($fieldValueEvent);
$pageHitEvent->setDecisionPath('yes');
$pageHitEvent->setChannel('page');
$pageHitEvent->setOrder(2);
$this->em->persist($pageHitEvent);
$this->em->flush();
$this->campaignWithoutJump = $campaign;
$campaignLead = new CampaignLead();
$campaignLead->setCampaign($this->campaignWithoutJump);
$campaignLead->setLead($this->lead);
$campaignLead->setDateAdded(new \DateTime());
$this->em->persist($campaignLead);
$this->em->flush();
$leadEventLog = new LeadEventLog();
$leadEventLog->setLead($this->lead);
$leadEventLog->setEvent($fieldValueEvent);
$leadEventLog->setIsScheduled(false);
$leadEventLog->setRotation(1);
$leadEventLog->setNonActionPathTaken(false);
$leadEventLog->setDateTriggered(new \DateTime());
$this->em->persist($leadEventLog);
$this->em->flush();
}
private function clearEm(): void
{
foreach ([Campaign::class, Event::class, LeadEventLog::class] as $entity) {
$this->em->clear($entity);
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
class DetailsTest extends MauticMysqlTestCase
{
public function testDetailsPageLoadCorrectly(): void
{
$campaign = new Campaign();
$campaign->setName('Campaign A');
$campaign->setCanvasSettings([
'nodes' => [
0 => [
'id' => '148',
'positionX' => '760',
'positionY' => '155',
],
1 => [
'id' => 'lists',
'positionX' => '860',
'positionY' => '50',
],
],
'connections' => [
0 => [
'sourceId' => 'lists',
'targetId' => '148',
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
],
]
);
$this->em->persist($campaign);
$this->em->flush();
$this->client->request('GET', sprintf('/s/campaigns/view/%s', $campaign->getId()));
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
Assert::assertStringContainsString($campaign->getName(), $response->getContent());
Assert::assertStringContainsString(sprintf('data-target-url="/s/campaigns/view/%s/contact/1"', $campaign->getId()), $response->getContent());
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Campaign;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\Lead as CampaignMember;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\Tag;
use PHPUnit\Framework\Assert;
final class JumpToActionTest extends MauticMysqlTestCase
{
/**
* @see https://github.com/mautic/mautic/pull/11568
*/
public function testInfiniteLoop(): void
{
$contact = new Lead();
$contact->setEmail('loop@expe.rt');
$contact->setDateIdentified(new \DateTime());
$contact->setLastActive(new \DateTime());
$tag = new Tag();
$tag->setTag('VisitedPageA');
$decision = new Event();
$decision->setOrder(1);
$decision->setName('URL is hit');
$decision->setType('page.pagehit');
$decision->setEventType('decision');
$decision->setProperties([
'url' => 'https://mautic.org',
]);
$addTag = new Event();
$addTag->setOrder(2);
$addTag->setParent($decision);
$addTag->setName('Add tag');
$addTag->setType('lead.changetags');
$addTag->setEventType('action');
$addTag->setTriggerInterval(1);
$addTag->setTriggerIntervalUnit('i');
$addTag->setTriggerMode('interval');
$addTag->setDecisionPath('yes');
$addTag->setProperties([
'add_tags' => ['VisitedPageA'],
]);
$jumpTo = new Event();
$jumpTo->setOrder(2);
$jumpTo->setParent($decision);
$jumpTo->setName('Jump to');
$jumpTo->setType('campaign.jump_to_event');
$jumpTo->setEventType('action');
$jumpTo->setTriggerInterval(1);
$jumpTo->setTriggerIntervalUnit('i');
$jumpTo->setTriggerMode('interval');
$jumpTo->setDecisionPath('no');
$campaign = new Campaign();
$campaign->addEvents([$decision, $addTag, $jumpTo]);
$campaign->setName('Campaign A');
$campaignMember = new CampaignMember();
$campaignMember->setLead($contact);
$campaignMember->setCampaign($campaign);
$campaignMember->setDateAdded(new \DateTime('-61 seconds'));
$decision->setCampaign($campaign);
$decision->addChild($addTag);
$decision->addChild($jumpTo);
$addTag->setCampaign($campaign);
$jumpTo->setCampaign($campaign);
$campaign->addLead(0, $campaignMember);
$this->em->persist($campaign);
$this->em->persist($decision);
$this->em->persist($addTag);
$this->em->persist($jumpTo);
$this->em->persist($contact);
$this->em->persist($campaignMember);
$this->em->persist($tag);
$this->em->flush();
$jumpTo->setProperties(['jumpToEvent' => $addTag->getId()]);
$campaign->setCanvasSettings(
[
'nodes' => [
[
'id' => $decision->getId(),
'positionX' => '1080',
'positionY' => '155',
],
[
'id' => $addTag->getId(),
'positionX' => '980',
'positionY' => '260',
],
[
'id' => $jumpTo->getId(),
'positionX' => '1220',
'positionY' => '260',
],
[
'id' => 'lists',
'positionX' => '860',
'positionY' => '1',
],
],
'connections' => [
[
'sourceId' => 'lists',
'targetId' => $decision->getId(),
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
[
'sourceId' => $decision->getId(),
'targetId' => $addTag->getId(),
'anchors' => [
'source' => 'yes',
'target' => 'top',
],
],
[
'sourceId' => $decision->getId(),
'targetId' => $jumpTo->getId(),
'anchors' => [
'source' => 'no',
'target' => 'top',
],
],
],
]
);
$this->em->persist($campaign);
$this->em->persist($jumpTo);
$this->em->flush();
$this->testSymfonyCommand('mautic:campaigns:trigger', ['-i' => $campaign->getId()]);
$eventLogs = $this->getEventLogsForContact($contact);
Assert::assertCount(3, $eventLogs, '3 event logs should be scheduled to be executed in 1 minute');
Assert::assertSame(['URL is hit', 'Jump to', 'Add tag'], $this->getEventNames($eventLogs));
// Time travel 2 minutes into the future:
foreach ($eventLogs as $eventLog) {
$eventLog->setTriggerDate(new \DateTime('-2 minutes'));
$eventLog->setDateTriggered(new \DateTime('-2 minutes'));
$this->em->persist($eventLog);
}
$this->em->flush();
$this->em->detach($eventLog);
$this->em->detach($jumpTo);
$this->em->detach($eventLog);
$this->em->detach($decision);
$this->em->detach($addTag);
$this->em->detach($campaignMember);
$this->em->detach($tag);
// Executing the command for the second time should not schedule any new events:
$this->testSymfonyCommand('mautic:campaigns:trigger', ['-i' => $campaign->getId()]);
$eventLogs = $this->getEventLogsForContact($contact);
Assert::assertCount(3, $eventLogs); // This was 6 before the fix.
Assert::assertSame(['URL is hit', 'Jump to', 'Add tag'], $this->getEventNames($eventLogs));
}
/**
* @return LeadEventLog[]
*/
private function getEventLogsForContact(Lead $contact): array
{
$eventLogRepository = $this->em->getRepository(LeadEventLog::class);
return $eventLogRepository->findBy(['lead' => $contact->getId()]);
}
/**
* @param LeadEventLog[] $eventLogs
*
* @return string[]
*/
private function getEventNames(array $eventLogs): array
{
return array_map(
fn (LeadEventLog $eventLog) => $eventLog->getEvent()->getName(),
$eventLogs
);
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Controller;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Translation\TranslatorInterface;
final class CampaignAuditLogTest extends MauticMysqlTestCase
{
use CreateTestEntitiesTrait;
public function testCampaignAuditLog(): void
{
// Create a Segment.
$segment = $this->createSegment('seg1', []);
$campaign = $this->createCampaign('Audit Log Campaign');
$campaign->addList($segment);
$campaign->setIsPublished(true);
$event = new Event();
$event->setName('Change points event');
$event->setType('lead.changepoints');
$event->setEventType('action');
$event->setOrder(1);
$event->setProperties(['points' => 21]);
$event->setTriggerMode('date');
$event->setTriggerDate(new \DateTime('2023-09-27 21:37'));
$event->setCampaign($campaign);
$this->em->persist($event);
$this->em->flush();
$this->em->clear();
$campaignId = $campaign->getId();
$eventId = $event->getId();
$modifiedEvents = []; // Initialize empty for consistency with API approach
// 2. Update the event through API to test EventController and create audit log.
// 2.b Get the event edit form.
$uri = "/s/campaigns/events/edit/{$eventId}?campaignId={$campaignId}&anchor=leadsource";
$this->client->xmlHttpRequest('GET', $uri, ['modifiedEvents' => json_encode($modifiedEvents)]);
$response = $this->client->getResponse();
$this->assertResponseIsSuccessful();
// Update the event.
$responseData = json_decode($response->getContent(), true);
$crawler = new Crawler($responseData['newContent'], $this->client->getInternalRequest()->getUri());
$form = $crawler->filterXPath('//form[@name="campaignevent"]')->form();
$form->setValues(
[
'campaignevent[canvasSettings][droppedX]' => '863',
'campaignevent[canvasSettings][droppedY]' => '363',
'campaignevent[name]' => '2 contact points after 1 day',
'campaignevent[triggerMode]' => 'interval',
'campaignevent[triggerDate]' => '2023-09-27 21:37',
'campaignevent[triggerInterval]' => '1',
'campaignevent[triggerIntervalUnit]' => 'd',
'campaignevent[triggerHour]' => '',
'campaignevent[triggerRestrictedStartHour]' => '',
'campaignevent[triggerRestrictedStopHour]' => '',
'campaignevent[anchor]' => 'no',
'campaignevent[properties][points]' => '2',
'campaignevent[properties][group]' => '',
'campaignevent[type]' => 'lead.changepoints',
'campaignevent[eventType]' => 'action',
'campaignevent[anchorEventType]' => 'condition',
'campaignevent[campaignId]' => $campaignId,
]
);
$this->setCsrfHeader();
$formData = $form->getPhpValues();
$formData['modifiedEvents'] = json_encode($modifiedEvents);
$this->client->xmlHttpRequest($form->getMethod(), $form->getUri(), $formData);
$response = $this->client->getResponse();
$this->assertResponseIsSuccessful();
$responseData = json_decode($response->getContent(), true);
$this->assertTrue($responseData['success'], print_r(json_decode($response->getContent(), true), true));
// 2.c Save campaign through CampaignModel to trigger audit log creation
$campaignModel = static::getContainer()->get('mautic.campaign.model.campaign');
$campaign = $campaignModel->getEntity($campaignId);
$event = $this->em->find(Event::class, $eventId);
$event->setName('2 contact points after 1 day');
$campaign->addEvent($eventId, $event);
$campaignModel->saveEntity($campaign);
$this->em->clear();
// 3. View the campaign.
$campaignViewUrl = '/s/campaigns/view/'.$campaignId;
$this->client->request(Request::METHOD_GET, $campaignViewUrl);
$this->assertResponseIsSuccessful();
$translator = static::getContainer()->get('translator');
\assert($translator instanceof TranslatorInterface);
$this->assertStringContainsString(
$translator->trans('mautic.campaign.changelog.event_updated'),
$this->client->getResponse()->getContent()
);
$this->assertStringContainsString(
$translator->trans('mautic.campaign.changelog.event_updated_details', ['%event_id%' => $eventId]),
$this->client->getResponse()->getContent()
);
}
public function testCampaignMultipleProjectAdditionsShowInAuditLog(): void
{
$campaignModel = CampaignAuditLogTest::getContainer()->get('mautic.campaign.model.campaign');
// Create projects first
$project1 = $this->createProject('First Project');
$project2 = $this->createProject('Second Project');
$this->em->flush();
// Create a campaign without projects
$campaign = $this->createCampaign('Campaign for Multiple Additions');
$campaignModel->saveEntity($campaign);
$this->em->flush();
$campaignId = $campaign->getId();
// Add first project
$campaign->addProject($project1);
$campaignModel->saveEntity($campaign);
$this->em->flush();
// Add second project
$campaign->addProject($project2);
$campaignModel->saveEntity($campaign);
$this->em->flush();
// View the campaign to see audit log
$campaignViewUrl = '/s/campaigns/view/'.$campaignId;
$this->client->request(Request::METHOD_GET, $campaignViewUrl);
$this->assertResponseIsSuccessful();
$responseContent = $this->client->getResponse()->getContent();
// Verify both project names appear
$this->assertStringContainsString('First Project', $responseContent);
$this->assertStringContainsString('Second Project', $responseContent);
// Should show the progression in audit log
$this->assertStringContainsString('First Project, Second Project', $responseContent);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Controller;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
final class CampaignBuilderEditFieldValueConditionTest extends MauticMysqlTestCase
{
use CampaignControllerTrait;
use CreateTestEntitiesTrait;
public function testCampaignBuilderFormForFieldValueConditionForInOperator(): void
{
$campaign = $this->setupCampaignWithLeadList();
$version = $campaign->getVersion();
$campaignCondition = $this->setupCampaignEvent($campaign);
$campaignAction = new Event();
$campaignAction->setCampaign($campaign);
$campaignAction->setParent($campaignCondition);
$campaignAction->setName('Send Email 1');
$campaignAction->setType('email.send');
$campaignAction->setEventType('action');
$campaignAction->setProperties([]);
$this->em->persist($campaignAction);
$this->em->flush();
$this->em->clear();
$conditionArray = $campaignCondition->convertToArray();
unset($conditionArray['campaign'], $conditionArray['children'], $conditionArray['log'], $conditionArray['changes']);
$campaignArray = $campaignAction->convertToArray();
unset($campaignArray['campaign'], $campaignArray['children'], $campaignArray['log'], $campaignArray['changes'], $campaignArray['parent']);
$modifiedEvents = [
$campaignCondition->getId() => $conditionArray,
$campaignAction->getId() => $campaignArray,
];
$payload = [
'modifiedEvents' => json_encode($modifiedEvents),
];
$this->client->request(Request::METHOD_POST, sprintf('/s/campaigns/events/edit/%s', $campaignCondition->getId()), $payload, [], $this->createAjaxHeaders());
Assert::assertTrue($this->client->getResponse()->isOk());
// version should be incremented as campaign's "modified by user" is updated
$this->refreshAndSubmitForm($campaign, ++$version);
}
public function testSwitchScalarValueToAnArrayOne(): void
{
$campaign = $this->setupCampaignWithLeadList();
$campaignCondition = $this->setupCampaignEvent($campaign);
// Start with a scalar value for the 'value' property
$campaignCondition->setProperties([
'field' => 'country',
'operator' => '=',
'value' => 'Afghanistan', // scalar value
]);
$this->em->flush();
$this->em->clear();
// Convert the event to array format and change from scalar to array value
$conditionArray = $campaignCondition->convertToArray();
unset($conditionArray['campaign'], $conditionArray['children'], $conditionArray['log'], $conditionArray['changes']);
// Change the operator to 'in' and value to array (this is the core test scenario)
$conditionArray['properties']['operator'] = 'in';
$conditionArray['properties']['value'] = ['Albania']; // array value
$modifiedEvents = [
$campaignCondition->getId() => $conditionArray,
];
$payload = [
'modifiedEvents' => json_encode($modifiedEvents),
];
// The main test: ensure the EventController can handle scalar to array conversion without HTTP 500
$this->client->request(Request::METHOD_POST, sprintf('/s/campaigns/events/edit/%s', $campaignCondition->getId()), $payload, [], $this->createAjaxHeaders());
$response = $this->client->getResponse();
// This is the core assertion - the request should succeed (no HTTP 500)
Assert::assertTrue($response->isOk(), 'EventController should handle scalar to array value conversion without HTTP 500');
// Additional verification: ensure response is valid JSON
Assert::assertJson($response->getContent());
}
private function setupCampaignWithLeadList(): Campaign
{
$leadList = new LeadList();
$leadList->setName('Test list');
$leadList->setPublicName('Test list');
$leadList->setAlias('test-list');
$this->em->persist($leadList);
$campaign = new Campaign();
$campaign->setName('Test campaign');
$campaign->addList($leadList);
$this->em->persist($campaign);
$lead = new Lead();
$lead->setFirstname('Test Lead');
$this->em->persist($lead);
return $campaign;
}
private function setupCampaignEvent(Campaign $campaign): Event
{
$campaignCondition = new Event();
$campaignCondition->setCampaign($campaign);
$campaignCondition->setName('Check for country');
$campaignCondition->setType('lead.field_value');
$campaignCondition->setEventType('condition');
$campaignCondition->setProperties([
'field' => 'country',
'operator' => 'in',
'value' => ['Afghanistan'],
]);
$this->em->persist($campaignCondition);
return $campaignCondition;
}
}

View File

@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Controller;
use Doctrine\ORM\Exception\NotSupported;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\Mapping\MappingException;
use GuzzleHttp\Utils;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\Lead as CampaignLead;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CoreBundle\Helper\ExportHelper;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Mautic\CoreBundle\Tests\Functional\UserEntityTrait;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Entity\UserRepository;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class CampaignControllerTest extends MauticMysqlTestCase
{
use CreateTestEntitiesTrait;
use UserEntityTrait;
private Lead $contactOne;
private Lead $contactTwo;
private Lead $contactThree;
private Campaign $campaign;
/**
* @throws NotSupported
* @throws ORMException
* @throws MappingException
*/
public function testContactsGridForValidPermissions(): void
{
$nonAdminUser = $this->setupCampaignData(38, 0);
$this->loginOtherUser($nonAdminUser);
$this->client->request(Request::METHOD_GET, '/s/campaigns/view/'.$this->campaign->getId().'/contact/1');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$content = $this->client->getResponse()->getContent();
$this->assertStringContainsString($this->contactOne->getName(), $content);
$this->assertStringContainsString($this->contactTwo->getName(), $content);
$this->assertStringContainsString($this->contactThree->getName(), $content);
}
/**
* @throws OptimisticLockException
* @throws MappingException
* @throws ORMException
* @throws NotSupported
*/
public function testContactsGridWhenIncompleteValidPermissions(): void
{
$nonAdminUser = $this->setupCampaignData(2, 0);
$this->loginOtherUser($nonAdminUser);
$this->client->request(Request::METHOD_GET, '/s/campaigns/view/'.$this->campaign->getId().'/contact/1');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$content = $this->client->getResponse()->getContent();
$this->assertStringContainsString('No Contacts Found', $content, $content);
}
/**
* @throws ORMException
* @throws MappingException
* @throws OptimisticLockException
* @throws NotSupported
*/
private function setupCampaignData(int $bitwise, int $export): User
{
/** @var UserRepository $userRepository */
$userRepository = $this->em->getRepository(User::class);
$adminUser = $userRepository->findOneBy(['username' => 'admin']);
// create users
$nonAdminUser = $this->createUserWithPermission([
'user-name' => 'non-admin',
'email' => 'non-admin@mautic-test.com',
'first-name' => 'non-admin',
'last-name' => 'non-admin',
'role' => [
'name' => 'perm_non_admin',
'permissions' => [
'lead:leads' => $bitwise,
'campaign:campaigns' => 2,
'campaign:export:enable' => $export,
],
],
]);
// create contacts
$this->contactOne = $this->createLead('John', '', '', $adminUser);
$this->contactTwo = $this->createLead('Alex', '', '', $adminUser);
$this->contactThree = $this->createLead('Gemini', '', '', $nonAdminUser);
// Create Segment
$segment = $this->createSegment('seg1', []);
// Add contacts to segment
$this->createListLead($segment, $this->contactOne);
$this->createListLead($segment, $this->contactTwo);
$this->createListLead($segment, $this->contactThree);
$this->campaign = $this->createCampaign('Campaign');
$this->campaign->addList($segment);
$this->createEvent('Add 10 points', $this->campaign,
'lead.changepoints',
'action',
['points' => 10]
);
$this->em->flush();
$this->em->clear();
$this->testSymfonyCommand('mautic:campaigns:update', ['--campaign-id' => $this->campaign->getId(), '-vv']);
return $nonAdminUser;
}
public function testCountsProcessedCampaignsMethodCountsProcessedCampaignsCorrectly(): void
{
$campaign = new Campaign();
$campaign->setName('Test Campaign');
$this->em->persist($campaign);
$lead = new Lead();
$lead->setFirstname('Test Lead');
$this->em->persist($lead);
$campaignEvent1 = new Event();
$campaignEvent1->setCampaign($campaign);
$campaignEvent1->setName('Send Email 1');
$campaignEvent1->setType('email.send');
$campaignEvent1->setEventType('action');
$campaignEvent1->setProperties([]);
$this->em->persist($campaignEvent1);
$campaignEvent2 = new Event();
$campaignEvent2->setCampaign($campaign);
$campaignEvent2->setName('Jump to send email 1');
$campaignEvent2->setType('campaign.jump_to_event');
$campaignEvent2->setEventType('action');
$campaignEvent2->setProperties([]);
$this->em->persist($campaignEvent2);
$campaignLead = new CampaignLead();
$campaignLead->setCampaign($campaign);
$campaignLead->setLead($lead);
$campaignLead->setDateAdded(new \DateTime());
$this->em->persist($campaignLead);
$leadEventLog1 = new LeadEventLog();
$leadEventLog1->setLead($lead);
$leadEventLog1->setEvent($campaignEvent1);
$leadEventLog1->setIsScheduled(true);
$leadEventLog1->setRotation(1);
$this->em->persist($leadEventLog1);
$leadEventLog2 = new LeadEventLog();
$leadEventLog2->setLead($lead);
$leadEventLog2->setEvent($campaignEvent2);
$leadEventLog1->setRotation(1);
$this->em->persist($leadEventLog2);
$leadEventLog3 = new LeadEventLog();
$leadEventLog3->setLead($lead);
$leadEventLog3->setEvent($campaignEvent1);
$leadEventLog1->setRotation(2);
$this->em->persist($leadEventLog3);
$this->em->flush();
$eventsStatistics = $this->getEventsStatistics($campaign);
$expectedEventsStatistics = [
0 => [
'successPercent' => '100%',
'completed' => '1',
'pending' => '1',
],
1 => [
'successPercent' => '100%',
'completed' => '1',
'pending' => '0',
],
];
Assert::assertSame($expectedEventsStatistics, $eventsStatistics, 'Events statistics doesn\'t match the actual events in the database.');
}
private function getCrawler(Campaign $campaign): Crawler
{
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$before = $now->modify('-1 month');
$after = $now->modify('+1 month');
$url = sprintf('s/campaigns/event/stats/%d/%s/%s', $campaign->getId(), $before->format('Y-m-d'), $after->format('Y-m-d'));
$this->client->request('GET', $url);
$response = $this->client->getResponse();
$body = Utils::jsonDecode($response->getContent(), true);
$this->client->restart();
return new Crawler($body['actions']);
}
/**
* @return array<array<string, string>>
*/
private function getEventsStatistics(Campaign $campaign): array
{
$crawler = $this->getCrawler($campaign);
$events = [];
for ($eventIndex = 0;; ++$eventIndex) {
$node = $crawler->filter('.campaign-event-list')->filter('span')->eq($eventIndex * 3);
if (1 > $node->count()) {
break;
}
$events[] = [
'successPercent' => trim($crawler->filter('.campaign-event-list')->filter('span')->eq($eventIndex * 3)->html()),
'completed' => trim($crawler->filter('.campaign-event-list')->filter('span')->eq($eventIndex * 3 + 1)->html()),
'pending' => trim($crawler->filter('.campaign-event-list')->filter('span')->eq($eventIndex * 3 + 2)->html()),
];
}
return $events;
}
public function testExportAction(): void
{
$nonAdminUser = $this->setupCampaignData(2, 1024);
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/export/'.$this->campaign->getId());
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
$this->assertSame('application/zip', $response->headers->get('Content-Type'));
$this->assertStringContainsString('.zip', $response->headers->get('Content-Disposition'));
}
public function testBatchExportAction(): void
{
$nonAdminUser = $this->setupCampaignData(2, 1024);
$this->loginOtherUser($nonAdminUser);
$this->client->request(
'GET',
'/s/campaigns/batchExport',
[
'filetype' => 'zip',
'ids' => json_encode([$this->campaign->getId()]),
]
);
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
$this->assertEquals('application/zip', $response->headers->get('Content-Type'));
$this->assertStringContainsString('.zip', (string) $response->headers->get('Content-Disposition'));
}
public function testExportActionAccessDenied(): void
{
$nonAdminUser = $this->setupCampaignData(2, 0);
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/export/'.$this->campaign->getId());
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
}
public function testExportActionCampaignNotFound(): void
{
$nonAdminUser = $this->setupCampaignData(38, 1024);
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/export/999999'); // Non-existent campaign ID
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode());
}
public function testExportFileNotCreated(): void
{
$nonAdminUser = $this->setupCampaignData(38, 1024); // Ensures export permission
// Mock the ExportHelper to simulate file creation failure
$exportHelperMock = $this->createMock(ExportHelper::class);
$exportHelperMock->method('writeToZipFile')->willReturn('');
// Inject the mock into the container
static::getContainer()->set(ExportHelper::class, $exportHelperMock);
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/export/'.$this->campaign->getId());
$response = $this->client->getResponse();
$responseContent = $response->getContent();
// Assert the response status and content
$this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
$this->assertJson($responseContent);
$responseData = json_decode($responseContent, true);
$this->assertArrayHasKey('error', $responseData);
$this->assertStringContainsString('Export file could not be created', $responseData['error']);
$this->assertArrayHasKey('flashes', $responseData);
}
public function testBatchExportAccessDenied(): void
{
$nonAdminUser = $this->setupCampaignData(0, 0); // No permissions for view or export
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/batchExport');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
}
public function testBatchExportCampaignQuery(): void
{
$nonAdminUser = $this->setupCampaignData(2, 1024); // Ensure view permissions
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/batchExport', [
'ids' => json_encode([]), // Empty IDs to trigger query
]);
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
}
public function testBatchExportFileNotCreated(): void
{
$nonAdminUser = $this->setupCampaignData(2, 1024); // Ensure view and export permissions
// Mock the ExportHelper to simulate file creation failure
$exportHelperMock = $this->createMock(ExportHelper::class);
$exportHelperMock->method('writeToZipFile')->willReturn('/invalid/path/to/file.zip');
// Use the test container to replace the service with the mock
static::getContainer()->set(ExportHelper::class, $exportHelperMock);
$this->loginOtherUser($nonAdminUser);
$this->client->request('GET', '/s/campaigns/batchExport', [
'ids' => json_encode([$this->campaign->getId()]),
]);
$response = $this->client->getResponse();
$responseContent = $response->getContent();
$this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
$this->assertJson($responseContent);
$responseData = json_decode($responseContent, true);
$this->assertArrayHasKey('error', $responseData);
$this->assertStringContainsString('Export file could not be created', $responseData['error']);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Mautic\CampaignBundle\Tests\Functional\Controller;
use Mautic\CampaignBundle\Entity\Campaign;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
trait CampaignControllerTrait
{
/**
* @param array<string,mixed> $formValues
*/
private function refreshAndSubmitForm(Campaign $campaign, int $expectedVersion, array $formValues = []): void
{
$crawler = $this->refreshPage($campaign);
$this->submitForm($crawler, $campaign, $expectedVersion, $formValues);
}
private function refreshPage(Campaign $campaign): Crawler
{
$crawler = $this->client->request('GET', sprintf('/s/campaigns/edit/%s', $campaign->getId()));
Assert::assertTrue($this->client->getResponse()->isOk());
Assert::assertStringContainsString('Edit Campaign', $crawler->text());
return $crawler;
}
/**
* @param array<string,mixed> $formValues
*/
private function submitForm(
Crawler $crawler,
Campaign $campaign,
int $expectedVersion,
array $formValues = [],
): Crawler {
$form = $crawler->selectButton('Save')->form();
$form->setValues($formValues);
$newCrawler = $this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
$this->em->clear();
$campaign = $this->em->find(Campaign::class, $campaign->getId());
Assert::assertSame($expectedVersion, $campaign->getVersion());
return $newCrawler;
}
/**
* Create canvas settings with a single connection from a source to an event.
*
* @return array<string, mixed>
*/
private function createCanvasSettings(int $eventId, string $sourceType = 'lists'): array
{
return [
'nodes' => [
[
'id' => $sourceType,
'positionX' => 100,
'positionY' => 100,
],
[
'id' => $eventId,
'positionX' => 300,
'positionY' => 100,
],
],
'connections' => [
[
'sourceId' => $sourceType,
'targetId' => $eventId,
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
],
];
}
/**
* Create canvas settings with connections for multiple events.
*
* @return array<string, mixed>
*/
private function createCanvasSettingsWithMultipleEvents(
int $firstEventId,
int $secondEventId,
string $sourceType = 'lists',
): array {
return [
'nodes' => [
[
'id' => $sourceType,
'positionX' => 100,
'positionY' => 100,
],
[
'id' => $firstEventId,
'positionX' => 300,
'positionY' => 100,
],
[
'id' => $secondEventId,
'positionX' => 500,
'positionY' => 100,
],
],
'connections' => [
[
'sourceId' => $sourceType,
'targetId' => $firstEventId,
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
[
'sourceId' => $firstEventId,
'targetId' => $secondEventId,
'anchors' => [
'source' => 'bottom',
'target' => 'top',
],
],
],
];
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Controller;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Tests\Functional\Fixtures\FixtureHelper;
use Mautic\CoreBundle\Event\EntityImportEvent;
use Mautic\CoreBundle\Helper\ImportHelper;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class CampaignImportControllerTest extends MauticMysqlTestCase
{
public function setUp(): void
{
$this->useCleanupRollback = false;
parent::setUp();
}
public function testNewAction(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$this->client->request(Request::METHOD_GET, '/s/campaign/import/new');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
$this->assertStringContainsString('campaignImport', $response->getContent());
}
public function testCancelAction(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
// Start the session by making a request
$this->client->request(Request::METHOD_GET, '/s/campaign/import/new');
$this->client->request(Request::METHOD_GET, '/s/campaign/import/cancel');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testProgressAction(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
// Start the session by making a request
$this->client->request(Request::METHOD_GET, '/s/campaign/import/new');
$this->client->request(Request::METHOD_GET, '/s/campaign/import/progress');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
$this->assertStringContainsString('campaignImport', $response->getContent());
}
public function testUndoAction(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
// Make a dummy request to initialize session
$this->client->request('GET', '/');
$session = $this->client->getRequest()->getSession();
// Simulate import summary with NEW entities to trigger undo
$session->set('mautic.campaign.import.summary', [
[
EntityImportEvent::NEW => [
Campaign::ENTITY_NAME => [
'ids' => [101, 102],
],
],
],
]);
$session->save();
$this->client->request('GET', '/s/campaign/import/undo');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
$this->assertStringContainsString('The last import has been undone successfully.', $response->getContent());
}
public function testUndoActionWithoutUndoData(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
// Dummy request to initialize session
$this->client->request('GET', '/');
$session = $this->client->getRequest()->getSession();
// Simulate import summary with only UPDATE (no NEW data)
$session->set('mautic.campaign.import.summary', [
[
EntityImportEvent::UPDATE => [
Campaign::ENTITY_NAME => [
'ids' => [201, 202],
],
],
],
]);
$session->save();
$this->client->request('GET', '/s/campaign/import/undo');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
$this->assertStringContainsString('No data found for import undo.', $response->getContent());
}
public function testProgressActionAnalyzeDataErrors(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$this->client->request('GET', '/');
$session = $this->client->getRequest()->getSession();
$session->set('mautic.campaign.import.step', 2);
$session->set('mautic.campaign.import.file', __DIR__.'/Fixtures/empty.zip');
$session->save();
$importHelper = $this->createMock(ImportHelper::class);
$importHelper->method('readZipFile')->willReturn([]);
static::getContainer()->set(ImportHelper::class, $importHelper);
$this->client->request('GET', '/s/campaign/import/progress');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testProgressActionAnalyzeDataValid(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$this->client->request('GET', '/');
$session = $this->client->getRequest()->getSession();
$fixturesDir = __DIR__.'/Fixtures';
if (!is_dir($fixturesDir)) {
mkdir($fixturesDir, 0775, true);
}
$fakePath = $fixturesDir.'/fake.zip';
file_put_contents($fakePath, 'dummy zip content');
$session->set('mautic.campaign.import.step', 2);
$session->set('mautic.campaign.import.file', $fakePath);
$session->save();
$importHelper = $this->createMock(ImportHelper::class);
$importHelper->method('readZipFile')->willReturn(FixtureHelper::getPayload());
static::getContainer()->set(ImportHelper::class, $importHelper);
$this->client->request('GET', '/s/campaign/import/progress');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
@unlink($fakePath);
}
public function testProgressActionImportEmptyFile(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$this->client->request('GET', '/');
$session = $this->client->getRequest()->getSession();
$session->set('mautic.campaign.import.step', 3);
$session->set('mautic.campaign.import.file', __DIR__.'/Fixtures/empty.zip');
$session->save();
$importHelper = $this->createMock(ImportHelper::class);
$importHelper->method('readZipFile')->willReturn([]);
static::getContainer()->set(ImportHelper::class, $importHelper);
$this->client->request('GET', '/s/campaign/import/progress');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testProgressActionImportValidData(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$this->client->request('GET', '/');
$session = $this->client->getRequest()->getSession();
$fixturesDir = __DIR__.'/Fixtures';
if (!is_dir($fixturesDir)) {
mkdir($fixturesDir, 0775, true);
}
$fakePath = $fixturesDir.'/fake.zip';
file_put_contents($fakePath, 'dummy zip content');
$session->set('mautic.campaign.import.step', 3);
$session->set('mautic.campaign.import.file', $fakePath);
$session->save();
$importHelper = $this->createMock(ImportHelper::class);
$importHelper->method('readZipFile')->willReturn(FixtureHelper::getPayload());
static::getContainer()->set(ImportHelper::class, $importHelper);
$this->client->request('GET', '/s/campaign/import/progress');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
@unlink($fakePath);
}
public function testUploadActionWithValidFile(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$tmpFile = tempnam(sys_get_temp_dir(), 'upl');
file_put_contents($tmpFile, 'dummy zip content');
$fileArray = [
'tmp_name' => $tmpFile,
'name' => 'test.zip',
'type' => 'application/zip',
'size' => filesize($tmpFile),
'error' => UPLOAD_ERR_OK,
];
$this->client->request(
'POST',
'/s/campaign/import/upload',
['campaign_import' => []], // POST data
['campaign_import' => ['campaignFile' => $fileArray]]
);
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
@unlink($tmpFile);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Controller;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use PHPUnit\Framework\Assert;
class CampaignOptimisticLockTest extends MauticMysqlTestCase
{
use CampaignControllerTrait;
/**
* @var string
*/
private const OPTIMISTIC_LOCK_ERROR = 'The record you are updating has been changed by someone else in the meantime. Please refresh the browser window and re-submit your changes.';
public function testOptimisticLock(): void
{
$campaign = $this->setupCampaign();
$version = $campaign->getVersion();
// version should be incremented as campaign's "modified by user" is updated
$this->refreshAndSubmitForm($campaign, ++$version);
// version should not be incremented as there are no changes
$this->refreshAndSubmitForm($campaign, $version);
// version should be incremented as there are changes
$this->refreshAndSubmitForm($campaign, ++$version, [
'campaign[allowRestart]' => '1',
'campaign[isPublished]' => '1',
]);
// version should not be incremented as there are no changes
$this->refreshAndSubmitForm($campaign, $version);
// refresh the page
$pageCrawler = $this->refreshPage($campaign);
// we should not get an optimistic lock error as the page was refreshed, version should be incremented
$crawler = $this->submitForm($pageCrawler, $campaign, ++$version, [
'campaign[allowRestart]' => '0',
]);
Assert::assertStringNotContainsString(self::OPTIMISTIC_LOCK_ERROR, $crawler->text());
// we should get an optimistic lock error as the page wasn't refreshed
$crawler = $this->submitForm($pageCrawler, $campaign, $version, [
'campaign[isPublished]' => '1',
]);
Assert::assertStringContainsString(self::OPTIMISTIC_LOCK_ERROR, $crawler->text());
// we should get an optimistic lock error even if there is no change
$crawler = $this->submitForm($pageCrawler, $campaign, $version);
Assert::assertStringContainsString(self::OPTIMISTIC_LOCK_ERROR, $crawler->text());
}
private function setupCampaign(): Campaign
{
$leadList = new LeadList();
$leadList->setName('Test list');
$leadList->setPublicName('Test list');
$leadList->setAlias('test-list');
$this->em->persist($leadList);
$campaign = new Campaign();
$campaign->setName('Test campaign');
$campaign->addList($leadList);
$this->em->persist($campaign);
$lead = new Lead();
$lead->setFirstname('Test Lead');
$this->em->persist($lead);
$campaignEvent = new Event();
$campaignEvent->setCampaign($campaign);
$campaignEvent->setName('Send Email 1');
$campaignEvent->setType('email.send');
$campaignEvent->setEventType('action');
$campaignEvent->setProperties([]);
$this->em->persist($campaignEvent);
$this->em->flush();
$canvasSettings = $this->createCanvasSettings($campaignEvent->getId());
$campaign->setCanvasSettings($canvasSettings);
$this->em->persist($campaign);
$this->em->flush();
$this->em->clear();
return $campaign;
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Entity;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
class LeadEventLogRepositoryTest extends MauticMysqlTestCase
{
private LeadEventLogRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = $this->em->getRepository(LeadEventLog::class);
}
public function testThatRemoveEventLogsMethodRemovesLogs(): void
{
$campaign = $this->createCampaign();
$event = $this->createEvent($campaign);
$this->createEventLog($campaign, $event);
$this->createEventLog($campaign, $event);
$this->createEventLog($campaign, $event);
$this->em->flush();
Assert::assertCount(3, $this->repository->findAll());
$this->repository->removeEventLogs([(string) $event->getId()]);
Assert::assertCount(0, $this->repository->findAll());
}
public function testMarkEventLogsQueued(): void
{
$campaign = $this->createCampaign();
$event = $this->createEvent($campaign);
$log1 = $this->createEventLog($campaign, $event);
$log2 = $this->createEventLog($campaign, $event);
$log3 = $this->createEventLog($campaign, $event);
$this->em->flush();
Assert::assertCount(3, $this->repository->findAll());
Assert::assertEmpty($log1->getDateQueued());
Assert::assertEmpty($log2->getDateQueued());
Assert::assertEmpty($log3->getDateQueued());
$this->repository->markEventLogsQueued([(string) $log1->getId(), (string) $log3->getId()]);
$this->em->refresh($log1);
$this->em->refresh($log2);
$this->em->refresh($log3);
Assert::assertNotEmpty($log1->getDateQueued());
Assert::assertEmpty($log2->getDateQueued());
Assert::assertNotEmpty($log3->getDateQueued());
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setFirstname('Test');
$this->em->persist($lead);
return $lead;
}
private function createCampaign(): Campaign
{
$campaign = new Campaign();
$campaign->setName('Campaign');
$this->em->persist($campaign);
return $campaign;
}
private function createEvent(Campaign $campaign): Event
{
$event = new Event();
$event->setName('Event');
$event->setCampaign($campaign);
$event->setType('page.devicehit');
$event->setEventType(Event::TYPE_DECISION);
$this->em->persist($event);
return $event;
}
private function createEventLog(Campaign $campaign, ?Event $event = null): LeadEventLog
{
$event = $event ?: $this->createEvent($campaign);
$lead = $this->createLead();
$leadEventLog = new LeadEventLog();
$leadEventLog->setLead($lead);
$leadEventLog->setEvent($event);
$leadEventLog->setTriggerDate(new \DateTime());
$leadEventLog->setIsScheduled(true);
$this->em->persist($leadEventLog);
return $leadEventLog;
}
}

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Fixtures;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\Lead as CampaignLead;
use Mautic\LeadBundle\Entity\Lead;
final class FixtureHelper
{
public function __construct(private EntityManagerInterface $em)
{
}
public function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
public function addContactToCampaign(Lead $contact, Campaign $campaign): CampaignLead
{
$ref = new CampaignLead();
$ref->setCampaign($campaign);
$ref->setLead($contact);
$ref->setDateAdded(new \DateTime());
$this->em->persist($ref);
return $ref;
}
public function createCampaign(string $name): Campaign
{
$campaign = new Campaign();
$campaign->setName($name);
$campaign->setIsPublished(true);
$this->em->persist($campaign);
return $campaign;
}
public function createCampaignWithScheduledEvent(Campaign $campaign, int $interval = 1, string $intervalUnit = 'd', ?\DateTimeInterface $hour = null): Event
{
if (!$campaign->getId()) {
$this->em->flush();
}
$event = new Event();
$event->setCampaign($campaign);
$event->setName('Adjust contact points');
$event->setType('lead.changepoints');
$event->setEventType('action');
$event->setTriggerInterval($interval);
$event->setTriggerIntervalUnit($intervalUnit);
$event->setTriggerMode('interval');
if ($hour) {
$event->setTriggerHour($hour->format('H:i'));
}
$event->setProperties(
[
'canvasSettings' => [
'droppedX' => '1080',
'droppedY' => '155',
],
'name' => '',
'triggerMode' => 'interval',
'triggerDate' => null,
'triggerInterval' => $interval,
'triggerIntervalUnit' => $intervalUnit,
'triggerHour' => $hour,
'triggerRestrictedStartHour' => '',
'triggerRestrictedStopHour' => '',
'anchor' => 'leadsource',
'properties' => ['points' => '5'],
'type' => 'lead.changepoints',
'eventType' => 'action',
'anchorEventType' => 'source',
'campaignId' => $campaign->getId(),
'buttons' => ['save' => ''],
'points' => 5,
]
);
$this->em->persist($event);
$this->em->flush();
$campaign->addEvent(0, $event);
$campaign->setCanvasSettings(
[
'nodes' => [
[
'id' => $event->getId(),
'positionX' => '1080',
'positionY' => '155',
],
[
'id' => 'lists',
'positionX' => '1180',
'positionY' => '50',
],
],
'connections' => [
[
'sourceId' => 'lists',
'targetId' => $event->getId(),
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
],
]
);
return $event;
}
/**
* Creates campaign with email sent action.
*
* Campaign diagram:
* -------------------
* - Start segment -
* -------------------
* |
* -------------------
* - Send email -
* -------------------
*
* @throws ORMException
* @throws OptimisticLockException
*/
public function createCampaignWithEmailSent(int $emailId): Campaign
{
$campaign = new Campaign();
$campaign->setName('Test send email');
$this->em->persist($campaign);
$this->em->flush();
$event1 = new Event();
$event1->setCampaign($campaign);
$event1->setName('Send email');
$event1->setType('email.send');
$event1->setChannel('email');
$event1->setChannelId($emailId);
$event1->setEventType('action');
$event1->setTriggerMode('immediate');
$event1->setOrder(1);
$event1->setProperties(
[
'canvasSettings' => [
'droppedX' => '549',
'droppedY' => '155',
],
'name' => '',
'triggerMode' => 'immediate',
'triggerDate' => null,
'triggerInterval' => '1',
'triggerIntervalUnit' => 'd',
'triggerHour' => '',
'triggerRestrictedStartHour' => '',
'triggerRestrictedStopHour' => '',
'anchor' => 'leadsource',
'properties' => [
'email' => $emailId,
'email_type' => 'transactional',
'priority' => '2',
'attempts' => '3',
],
'type' => 'email.send',
'eventType' => 'action',
'anchorEventType' => 'source',
'campaignId' => 'mautic_ce6c7dddf8444e579d741c0125f18b33a5d49b45',
'_token' => 'HgysZwvH_n0uAp47CcAcsGddRnRk65t-3crOnuLx28Y',
'buttons' => [
'save' => '',
],
'email' => $emailId,
'email_type' => 'transactional',
'priority' => 2,
'attempts' => 3.0,
]
);
$this->em->persist($event1);
$this->em->flush();
$campaign->setCanvasSettings(
[
'nodes' => [
[
'id' => $event1->getId(),
'positionX' => '549',
'positionY' => '155',
],
[
'id' => 'lists',
'positionX' => '796',
'positionY' => '50',
],
],
'connections' => [
[
'sourceId' => 'lists',
'targetId' => $event1->getId(),
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
],
]
);
$campaign->addEvent($event1->getId(), $event1);
$this->em->persist($campaign);
$this->em->flush();
return $campaign;
}
/** @return array<int, array<string, mixed>> */
public static function getPayload(): array
{
$fileContents = file_get_contents(__DIR__.'/entity_data.json');
return json_decode($fileContents, true);
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Form\Validator\Constraints;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadList;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
final class InfiniteLoopValidatorFunctionalTest extends MauticMysqlTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('delayDataProvider')]
public function testSubmitCampaignActionVariousDelayOptions(string $triggerMode, int $triggerInterval, string $triggerIntervalUnit, int $success, string $expectedString): void
{
$uri = '/s/campaigns/events/new?type=campaign.addremovelead&eventType=action&campaignId=mautic_89f7f52426c1dff3daa3beaea708a6b39fe7a775&anchor=leadsource&anchorEventType=source';
$this->client->xmlHttpRequest('GET', $uri);
$response = $this->client->getResponse();
$this->assertResponseIsSuccessful();
$responseData = json_decode($response->getContent(), true);
$crawler = new Crawler($responseData['newContent'], $this->client->getInternalRequest()->getUri());
$form = $crawler->filterXPath('//form[@name="campaignevent"]')->form();
$form->setValues(
[
'campaignevent[anchor]' => 'leadsource',
'campaignevent[properties][addTo]' => ['this'],
'campaignevent[type]' => 'campaign.addremovelead',
'campaignevent[eventType]' => 'action',
'campaignevent[anchorEventType]' => 'source',
'campaignevent[triggerMode]' => $triggerMode,
'campaignevent[triggerInterval]' => $triggerInterval,
'campaignevent[triggerIntervalUnit]' => $triggerIntervalUnit,
'campaignevent[campaignId]' => 'mautic_89f7f52426c1dff3daa3beaea708a6b39fe7a775',
]
);
$this->setCsrfHeader();
$this->client->xmlHttpRequest($form->getMethod(), $form->getUri(), $form->getPhpValues());
$response = $this->client->getResponse();
$this->assertResponseIsSuccessful();
$responseData = json_decode($response->getContent(), true);
Assert::assertSame($success, $responseData['success'], $response->getContent());
if ($expectedString) {
Assert::assertStringContainsString($expectedString, $responseData['newContent']);
}
}
/**
* @return iterable<string,array<string|int>>
*/
public static function delayDataProvider(): iterable
{
yield 'The immediate mode cannot be allowed otherwise the contacts will loop too fast for no reason' => [
'immediate',
1,
'i',
0,
'Campaign cannot restart itself without a delay. Please add at least 30 minute delay.',
];
yield 'The interval mode with less than 30 minutes cannot be allowed' => [
'interval',
29,
'i',
0,
'Your delay is only 29 minutes. It must be at least 30 minutes.',
];
yield 'The interval mode with 30 minutes or more should be allowed' => [
'interval',
30,
'i',
1,
'',
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('delayDataProvider')]
public function testValidationViaCampaignApi(string $triggerMode, int $triggerInterval, string $triggerIntervalUnit, int $success, string $expectedString): void
{
$segment = new LeadList();
$segment->setName('Test');
$segment->setPublicName('Test');
$segment->setAlias('test');
$this->em->persist($segment);
$this->em->flush();
$payload = [
'name' => 'Loop test',
'events' => [
[
'id' => 'new_30',
'name' => 'Change campaigns',
'type' => 'campaign.addremovelead',
'eventType' => 'action',
'properties' => [
'canvasSettings' => [
'droppedX' => '833',
'droppedY' => '155',
],
'triggerMode' => $triggerMode,
'triggerInterval' => $triggerInterval,
'triggerIntervalUnit' => $triggerIntervalUnit,
'anchor' => 'leadsource',
'properties' => [
'addTo' => [
'this',
],
],
'type' => 'campaign.addremovelead',
'eventType' => 'action',
'anchorEventType' => 'source',
'campaignId' => 'mautic_5d0923689420c9d3981255dc56b6308b92db82c2',
'_token' => 'pDmdgUFBm2tj-Vu8IoAfiaVNYy8sdBNjwrGtO9Igut8',
'addTo' => ['this'],
'removeFrom' => [],
],
'triggerMode' => $triggerMode,
'triggerInterval' => $triggerInterval,
'triggerIntervalUnit' => $triggerIntervalUnit,
'decisionPath' => null,
'parent' => null,
'children' => [],
],
[
'id' => 'new_31',
'name' => 'Change points',
'type' => 'lead.changepoints',
'eventType' => 'action',
'properties' => [
'canvasSettings' => [
'droppedX' => '933',
'droppedY' => '255',
],
'triggerMode' => $triggerMode,
'triggerInterval' => $triggerInterval,
'triggerIntervalUnit' => $triggerIntervalUnit,
'anchor' => 'leadsource',
'properties' => [
'points' => 2,
],
'type' => 'lead.changepoints',
'eventType' => 'action',
'anchorEventType' => 'source',
'campaignId' => 'mautic_5d0923689420c9d3981255dc56b6308b92db82c2',
'_token' => 'pDmdgUFBm2tj-Vu8IoAfiaVNYy8sdBNjwrGtO9Igut8',
'points' => 2,
],
'triggerMode' => $triggerMode,
'triggerInterval' => $triggerInterval,
'triggerIntervalUnit' => $triggerIntervalUnit,
'decisionPath' => null,
'parent' => null,
'children' => [],
],
],
'lists' => [$segment->getId()],
'canvasSettings' => [
'nodes' => [
[
'id' => 'new_30',
'positionX' => 833,
'positionY' => 155,
],
[
'id' => 'new_31',
'positionX' => 833,
'positionY' => 155,
],
[
'id' => 'lists',
'positionX' => 933,
'positionY' => 50,
],
],
'connections' => [
[
'sourceId' => 'lists',
'targetId' => 'new_30',
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
[
'sourceId' => 'lists',
'targetId' => 'new_31',
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
],
],
];
$expectedStatusCode = $success ? 201 : 422;
$this->client->request('POST', '/api/campaigns/new', $payload);
$response = $this->client->getResponse();
self::assertResponseStatusCodeSame($expectedStatusCode, $response->getContent());
if ($expectedString) {
Assert::assertStringContainsString($expectedString, $response->getContent());
}
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Tests\Functional\Validator;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Tests\Functional\Controller\CampaignControllerTrait;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\FormBundle\Entity\Form;
use Mautic\LeadBundle\Entity\LeadList;
use PHPUnit\Framework\Assert;
final class OrphanEventsValidationFunctionalTest extends MauticMysqlTestCase
{
use CampaignControllerTrait;
private const ORPHAN_EVENTS_ERROR_MESSAGE =
'One or more events are orphaned and must be linked to a node before proceeding';
public function testCampaignWithConnectedEventsShouldSaveSuccessfully(): void
{
$campaign = $this->setupCampaignWithConnectedEvent();
$version = $campaign->getVersion();
// Campaign with properly connected events should save without validation errors
$this->refreshAndSubmitForm($campaign, ++$version);
}
public function testCampaignWithOrphanEventsShouldFailValidation(): void
{
$campaign = $this->setupCampaignWithOrphanEvent();
$version = $campaign->getVersion();
// Submit the form and expect validation to prevent save (version should not increment)
$this->submitFormExpectingValidationFailure($campaign, $version);
}
public function testCampaignWithMixedConnectedAndOrphanEventsShouldFailValidation(): void
{
$campaign = $this->setupCampaignWithMixedEvents();
$version = $campaign->getVersion();
// Submit the form and expect validation to prevent save
$this->submitFormExpectingValidationFailure($campaign, $version);
}
public function testCampaignWithChainedEventsShouldSaveSuccessfully(): void
{
$campaign = $this->setupCampaignWithChainedEvents();
$version = $campaign->getVersion();
// Campaign with properly chained events should save without validation errors
$this->refreshAndSubmitForm($campaign, ++$version);
}
public function testCampaignWithFormAsSourceShouldSaveSuccessfully(): void
{
$campaign = $this->setupCampaignWithFormAsSource();
$version = $campaign->getVersion();
// Campaign with form as source and properly connected events should save without validation errors
$this->refreshAndSubmitForm($campaign, ++$version);
}
public function testCampaignWithFormAsSourceAndOrphanEventsShouldFailValidation(): void
{
$campaign = $this->setupCampaignWithFormAsSourceAndOrphanEvents();
$version = $campaign->getVersion();
// Submit the form and expect validation to prevent save due to orphan events
$this->submitFormExpectingValidationFailure($campaign, $version);
}
private function setupCampaignWithConnectedEvent(): Campaign
{
$campaign = $this->createBaseCampaign();
$this->addConnectedEventToCampaign($campaign);
return $campaign;
}
private function setupCampaignWithOrphanEvent(): Campaign
{
$campaign = $this->createBaseCampaign();
// Create an event but don't connect it via canvas settings (orphan)
$orphanEvent = $this->createOrphanEvent($campaign);
// Set up canvas settings with the orphan event in nodes but not in connections
$canvasSettings = [
'nodes' => [
[
'id' => 'lists',
'positionX' => 100,
'positionY' => 100,
],
[
'id' => $orphanEvent->getId(),
'positionX' => 300,
'positionY' => 100,
],
],
'connections' => [], // No connections - makes it an orphan
];
$campaign->setCanvasSettings($canvasSettings);
$this->em->persist($campaign);
$this->em->flush();
$this->em->clear();
return $campaign;
}
private function setupCampaignWithMixedEvents(): Campaign
{
$campaign = $this->createBaseCampaign();
// Create a connected event
$connectedEvent = new Event();
$connectedEvent->setCampaign($campaign);
$connectedEvent->setName('Connected Email');
$connectedEvent->setType('email.send');
$connectedEvent->setEventType('action');
$connectedEvent->setProperties([]);
$this->em->persist($connectedEvent);
// Create an orphan event
$orphanEvent = $this->createOrphanEvent($campaign);
// Create canvas settings where only the first event is connected (second is orphan)
$baseSettings = $this->createCanvasSettings($connectedEvent->getId());
// Add the orphan event to nodes but don't connect it
$canvasSettings = $baseSettings;
$canvasSettings['nodes'][] = [
'id' => $orphanEvent->getId(),
'positionX' => 500,
'positionY' => 100,
];
$campaign->setCanvasSettings($canvasSettings);
$this->em->persist($campaign);
$this->em->flush();
$this->em->clear();
return $campaign;
}
private function setupCampaignWithChainedEvents(): Campaign
{
$campaign = $this->createBaseCampaign();
// Create a condition event
$conditionEvent = new Event();
$conditionEvent->setCampaign($campaign);
$conditionEvent->setName('Check Country');
$conditionEvent->setType('lead.field_value');
$conditionEvent->setEventType('condition');
$conditionEvent->setProperties([
'field' => 'country',
'operator' => '=',
'value' => 'United States',
]);
$this->em->persist($conditionEvent);
// Create an action event chained to the condition using setParent()
$actionEvent = new Event();
$actionEvent->setCampaign($campaign);
$actionEvent->setParent($conditionEvent);
$actionEvent->setName('Send US Email');
$actionEvent->setType('email.send');
$actionEvent->setEventType('action');
$actionEvent->setProperties([]);
$this->em->persist($actionEvent);
$this->em->flush();
// Connect both events via canvas settings
$canvasSettings = $this->createCanvasSettingsWithMultipleEvents(
$conditionEvent->getId(),
$actionEvent->getId()
);
$campaign->setCanvasSettings($canvasSettings);
$this->em->persist($campaign);
$this->em->flush();
$this->em->clear();
return $campaign;
}
private function setupCampaignWithFormAsSource(): Campaign
{
$campaign = $this->createBaseCampaignWithForm();
$this->addConnectedEventToCampaign($campaign, 'forms');
return $campaign;
}
private function setupCampaignWithFormAsSourceAndOrphanEvents(): Campaign
{
$campaign = $this->createBaseCampaignWithForm();
$this->addConnectedEventToCampaign($campaign, 'forms');
// Reload campaign after addConnectedEventToCampaign() clears the entity manager
$campaign = $this->em->find(Campaign::class, $campaign->getId());
// Create an orphan event
$orphanEvent = $this->createOrphanEvent($campaign);
// Add orphan event to canvas settings without connections
$canvasSettings = $campaign->getCanvasSettings();
$canvasSettings['nodes'][] = [
'id' => $orphanEvent->getId(),
'positionX' => 500,
'positionY' => 100,
];
$campaign->setCanvasSettings($canvasSettings);
$this->em->persist($campaign);
$this->em->flush();
$this->em->clear();
return $campaign;
}
private function createBaseCampaign(): Campaign
{
$leadList = new LeadList();
$leadList->setName('Test list');
$leadList->setAlias('test-list');
$leadList->setPublicName('Test list');
$this->em->persist($leadList);
$campaign = new Campaign();
$campaign->setName('Test campaign');
$campaign->setIsPublished(true);
$campaign->setPublishUp(new \DateTime());
$campaign->addList($leadList);
$this->em->persist($campaign);
return $campaign;
}
private function createBaseCampaignWithForm(): Campaign
{
$form = new Form();
$form->setName('Test form');
$form->setAlias('test-form');
$this->em->persist($form);
$campaign = new Campaign();
$campaign->setName('Test campaign with form');
$campaign->setIsPublished(true);
$campaign->setPublishUp(new \DateTime());
$campaign->addForm($form);
$this->em->persist($campaign);
return $campaign;
}
private function submitFormExpectingValidationFailure(Campaign $campaign, int $originalVersion): void
{
// Submit the form and expect validation to prevent save (version should not increment)
$crawler = $this->refreshPage($campaign);
$form = $crawler->selectButton('Save')->form();
$newCrawler = $this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
// Verify the validation error message is displayed
Assert::assertStringContainsString(
self::ORPHAN_EVENTS_ERROR_MESSAGE,
$newCrawler->text()
);
// Verify the campaign version was not incremented (save was prevented)
$this->em->clear();
$campaign = $this->em->find(Campaign::class, $campaign->getId());
Assert::assertSame($originalVersion, $campaign->getVersion());
}
private function addConnectedEventToCampaign(Campaign $campaign, string $sourceType = 'lists'): void
{
$event = new Event();
$event->setCampaign($campaign);
$event->setName('Send Welcome Email');
$event->setType('email.send');
$event->setEventType('action');
$event->setProperties([]);
$this->em->persist($event);
$this->em->flush();
// Connect the event to source via canvas settings
$canvasSettings = $this->createCanvasSettings($event->getId(), $sourceType);
$campaign->setCanvasSettings($canvasSettings);
$this->em->persist($campaign);
$this->em->flush();
$this->em->clear();
}
private function createOrphanEvent(Campaign $campaign): Event
{
$orphanEvent = new Event();
$orphanEvent->setCampaign($campaign);
$orphanEvent->setName('Orphan Email');
$orphanEvent->setType('email.send');
$orphanEvent->setEventType('action');
$orphanEvent->setProperties([]);
$this->em->persist($orphanEvent);
$this->em->flush();
return $orphanEvent;
}
}