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,79 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Controller;
use Mautic\CoreBundle\Helper\ClickthroughHelper;
use Mautic\CoreBundle\Test\IsolatedTestTrait;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Entity\DynamicContentLeadData;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
class DynamicContentApiControllerFunctionalTest extends MauticMysqlTestCase
{
use IsolatedTestTrait;
public function testDwcGetEndpointForNoSlotNorContact(): void
{
$this->client->request(Request::METHOD_GET, '/dwc/slot-a');
self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT, $this->client->getResponse()->getContent());
}
public function testDwcGetEndpointForASlotAndContact(): void
{
$contact = new Lead();
$contact->setEmail('johana@doe.email');
$dwc = new DynamicContent();
$dwc->setContent('<some>content</some>');
$dwc->setName('Slot A');
$dwc->setSlotName('slot-a');
$dwcContact = new DynamicContentLeadData();
$dwcContact->setDateAdded(new \DateTime());
$dwcContact->setDynamicContent($dwc);
$dwcContact->setLead($contact);
$dwcContact->setSlot($dwc->getSlotName());
$stat = new Stat();
$stat->setLead($contact);
$stat->setTrackingHash('tracking-hash-1');
$stat->setEmailAddress($contact->getEmail());
$stat->setDateSent(new \DateTime());
$this->em->persist($contact);
$this->em->persist($stat);
$this->em->persist($dwc);
$this->em->persist($dwcContact);
$this->em->flush();
$ct = ClickthroughHelper::encodeArrayForUrl(['stat' => 'tracking-hash-1']);
$this->client->request(Request::METHOD_GET, "/dwc/slot-a?ct={$ct}");
self::assertResponseIsSuccessful($this->client->getResponse()->getContent());
$responseArray = json_decode($this->client->getResponse()->getContent(), true);
Assert::assertSame('<some>content</some>', $responseArray['content']);
}
public function testCreateDwc(): void
{
$payload = [
'name' => 'API test',
'content' => 'API test',
];
$this->client->request(Request::METHOD_POST, '/api/dynamiccontents/new', $payload);
self::assertResponseStatusCodeSame(Response::HTTP_CREATED, $this->client->getResponse()->getContent());
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\UserBundle\Entity\Permission;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
class DynamicContentControllerFunctionalTest extends MauticMysqlTestCase
{
public const PERMISSION_CREATE = 'dynamiccontent:dynamiccontents:create';
public const PERMISSION_DELETE_OTHER = 'dynamiccontent:dynamiccontents:deleteother';
public const PERMISSION_DELETE_OWN = 'dynamiccontent:dynamiccontents:deleteown';
public const BITWISE_BY_PERM = [
self::PERMISSION_CREATE => 52,
self::PERMISSION_DELETE_OWN => 66,
self::PERMISSION_DELETE_OTHER => 150,
];
private const NO_NESTING_VALIDATION_MESSAGE = 'DWC tokens cannot be used within another DWC. Please remove any DWC tokens from the content to proceed.';
public function testAccessControlNewAction(): void
{
$this->createAndLoginUser(self::PERMISSION_CREATE);
$this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testNoNestingValidationNewAction(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertTrue($this->client->getResponse()->isOk());
$this->submitFormAndAssertNoNestingValidation($crawler);
}
public function testForbiddenNewAction(): void
{
$this->createAndLoginUser();
$this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testNoNestingValidationEditAction(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertTrue($this->client->getResponse()->isOk());
$buttonCrawler = $crawler->selectButton('Save');
$form = $buttonCrawler->form();
$form->setValues([
'dwc[name]' => 'Some name',
'dwc[content]' => 'Some content',
]);
$crawler = $this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
Assert::assertStringNotContainsString(self::NO_NESTING_VALIDATION_MESSAGE, $crawler->text());
Assert::assertStringContainsString('Edit Dynamic Content', $crawler->text());
$this->submitFormAndAssertNoNestingValidation($crawler);
}
public function testAccessDeleteAction(): void
{
$this->createAndLoginUser(self::PERMISSION_DELETE_OWN);
$this->client->request(Request::METHOD_POST, '/s/dwc/delete');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testForbiddenDeleteAction(): void
{
$this->createAndLoginUser();
$this->client->request('GET', '/s/dwc/delete');
Assert::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testDwcWithProject(): void
{
$dynamicContent = new DynamicContent();
$dynamicContent->setName('test');
$this->em->persist($dynamicContent);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/dwc/edit/'.$dynamicContent->getId());
$form = $crawler->selectButton('Save')->form();
$form['dwc[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedAsset = $this->em->find(DynamicContent::class, $dynamicContent->getId());
Assert::assertSame($project->getId(), $savedAsset->getProjects()->first()->getId());
}
private function createAndLoginUser(?string $permission = null): User
{
// Create non-admin role
$role = $this->createRole();
// Create permissions to update user for the role
if (!empty($permission)) {
$this->createPermission($permission, $role, self::BITWISE_BY_PERM[$permission]);
}
// Create non-admin user
$user = $this->createUser($role);
$this->em->flush();
$this->em->detach($role);
$this->loginUser($user);
$this->client->setServerParameter('PHP_AUTH_USER', $user->getUserIdentifier());
$this->client->setServerParameter('PHP_AUTH_PW', 'Maut1cR0cks!');
return $user;
}
private function createRole(bool $isAdmin = false): Role
{
$role = new Role();
$role->setName('Role');
$role->setIsAdmin($isAdmin);
$this->em->persist($role);
return $role;
}
private function createPermission(string $rawPermission, Role $role, int $bitwise): void
{
$parts = explode(':', $rawPermission);
$permission = new Permission();
$permission->setBundle($parts[0]);
$permission->setName($parts[1]);
$permission->setRole($role);
$permission->setBitwise($bitwise);
$this->em->persist($permission);
}
private function createUser(Role $role): User
{
$user = new User();
$user->setFirstName('John');
$user->setLastName('Doe');
$user->setUsername('john.doe');
$user->setEmail('john.doe@email.com');
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
\assert($hasher instanceof PasswordHasherInterface);
$user->setPassword($hasher->hash('Maut1cR0cks!'));
$user->setRole($role);
$this->em->persist($user);
return $user;
}
public function testIndexActionIsSuccessful(): void
{
$this->client->request(Request::METHOD_GET, '/s/dwc');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testNewActionIsSuccessful(): void
{
$this->client->request(Request::METHOD_GET, '/s/dwc/new');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testEditActionIsSuccessful(): void
{
$entity = new DynamicContent();
$entity->setName('Test Dynamic Content');
$this->em->persist($entity);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/dwc/edit/'.$entity->getId());
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testViewActionIsSuccessful(): void
{
$entity = new DynamicContent();
$entity->setName('Test Dynamic Content');
$this->em->persist($entity);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/dwc/view/'.$entity->getId());
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
private function submitFormAndAssertNoNestingValidation(Crawler $crawler): void
{
$buttonCrawler = $crawler->selectButton('Save');
$form = $buttonCrawler->form();
$form->setValues([
'dwc[name]' => 'Some name',
'dwc[content]' => 'Some {dwc=slotname}',
]);
$crawler = $this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
Assert::assertStringContainsString(self::NO_NESTING_VALIDATION_MESSAGE, $crawler->text());
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Controller;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class DynamicContentProjectSearchFunctionalTest extends AbstractProjectSearchTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void
{
$projectOne = $this->createProject('Project One');
$projectTwo = $this->createProject('Project Two');
$projectThree = $this->createProject('Project Three');
$dynamicContentAlpha = $this->createDynamicContent('DynamicContent Alpha');
$dynamicContentBeta = $this->createDynamicContent('DynamicContent Beta');
$this->createDynamicContent('DynamicContent Gamma');
$this->createDynamicContent('DynamicContent Delta');
$dynamicContentAlpha->addProject($projectOne);
$dynamicContentAlpha->addProject($projectTwo);
$dynamicContentBeta->addProject($projectTwo);
$dynamicContentBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/dynamiccontents', '/s/dwc']);
}
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
public static function searchDataProvider(): \Generator
{
yield 'search by one project' => [
'searchTerm' => 'project:"Project Two"',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by one project AND dynamicContent name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by one project OR dynamicContent name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta', 'DynamicContent Gamma'],
'unexpectedEntities' => ['DynamicContent Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Gamma', 'DynamicContent Delta'],
'unexpectedEntities' => ['DynamicContent Beta'],
];
}
private function createDynamicContent(string $name): DynamicContent
{
$dynamicContent = new DynamicContent();
$dynamicContent->setName($name);
$this->em->persist($dynamicContent);
return $dynamicContent;
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Form\Type;
use DeviceDetector\Parser\Device\AbstractDeviceParser as DeviceParser;
use DeviceDetector\Parser\OperatingSystem;
use Doctrine\ORM\EntityManager;
use Mautic\DynamicContentBundle\DynamicContent\TypeList;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentListType;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentType;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Helper\FormFieldHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Model\ListModel;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Contracts\Translation\TranslatorInterface;
class DynamicContentTypeTest extends TestCase
{
public function testFormBuild(): void
{
$entityManagerMock = $this->createMock(EntityManager::class);
$listModelMock = $this->createMock(ListModel::class);
$translatorInterfaceMock = $this->createMock(TranslatorInterface::class);
$leadModelMock = $this->createMock(LeadModel::class);
$listModelMock->expects($this->once())
->method('getChoiceFields')
->willReturn($this->getMockChoiceFields());
$leadRepositoryMock = $this->createMock(LeadRepository::class);
$leadModelMock->expects($this->once())
->method('getRepository')
->willReturn($leadRepositoryMock);
$leadRepositoryMock->expects($this->once())
->method('getCustomFieldList')
->with('lead')
->willReturn($this->getMockCustomFieldList());
$tags = $this->getMockTagList();
$leadModelMock->expects($this->once())
->method('getTagList')
->willReturn($tags);
$dynamicContentType = new DynamicContentType(
$entityManagerMock,
$listModelMock,
$translatorInterfaceMock,
$leadModelMock,
new TypeList(),
);
$formBuilderInterfaceMock = $this->createMock(FormBuilderInterface::class);
$options['data'] = new DynamicContent();
$tagChoices = [];
foreach ($tags as $tag) {
$tagChoices[$tag['value']] = $tag['label'];
}
$matcher = $this->exactly(2);
$formBuilderInterfaceMock->expects($matcher)
->method('create')->willReturnCallback(function (...$parameters) use ($matcher, $tagChoices, $formBuilderInterfaceMock) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame('translationParent', $parameters[0]);
$this->assertSame(DynamicContentListType::class, $parameters[1]);
$this->assertSame([
'label' => 'mautic.core.form.translation_parent',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.form.translation_parent.help',
],
'required' => false,
'multiple' => false,
'placeholder' => 'mautic.core.form.translation_parent.empty',
'top_level' => 'translation',
'ignore_ids' => [0 => 0],
], $parameters[2]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame('filters', $parameters[0]);
$this->assertSame(CollectionType::class, $parameters[1]);
$this->assertSame([
'entry_type' => \Mautic\DynamicContentBundle\Form\Type\DwcEntryFiltersType::class,
'entry_options' => [
'countries' => FormFieldHelper::getCountryChoices(),
'regions' => FormFieldHelper::getRegionChoices(),
'timezones' => FormFieldHelper::getTimezonesChoices(),
'locales' => FormFieldHelper::getLocaleChoices(),
'fields' => $this->getMockChoiceFields(),
'deviceTypes' => array_combine(
DeviceParser::getAvailableDeviceTypeNames(),
DeviceParser::getAvailableDeviceTypeNames()
),
'deviceBrands' => DeviceParser::$deviceBrands,
'deviceOs' => array_combine(
array_keys(OperatingSystem::getAvailableOperatingSystemFamilies()),
array_keys(OperatingSystem::getAvailableOperatingSystemFamilies())
),
'tags' => $tagChoices,
],
'error_bubbling' => false,
'mapped' => true,
'allow_add' => true,
'allow_delete' => true,
], $parameters[2]);
}
return $formBuilderInterfaceMock;
});
$matcher = $this->exactly(3);
$formBuilderInterfaceMock->expects($matcher)
->method('addEventListener')->willReturnCallback(function (...$parameters) use ($matcher, $formBuilderInterfaceMock) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame(FormEvents::PRE_SUBMIT, $parameters[0]);
$callback = function ($listener) {
$reflection = new \ReflectionFunction($listener);
$parameters = $reflection->getParameters();
return FormEvent::class === (string) $parameters[0]->getType();
};
$this->assertTrue($callback($parameters[1]));
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame(FormEvents::PRE_SET_DATA, $parameters[0]);
$callback = function ($listener) {
$reflection = new \ReflectionFunction($listener);
$parameters = $reflection->getParameters();
return FormEvent::class === (string) $parameters[0]->getType();
};
$this->assertTrue($callback($parameters[1]));
}
if (3 === $matcher->numberOfInvocations()) {
$this->assertSame(FormEvents::POST_SUBMIT, $parameters[0]);
$callback = function ($listener) {
$reflection = new \ReflectionFunction($listener);
$parameters = $reflection->getParameters();
return FormEvent::class === (string) $parameters[0]->getType();
};
$this->assertTrue($callback($parameters[1]));
}
return $formBuilderInterfaceMock;
});
$formBuilderInterfaceMock->expects($this->once())
->method('get')
->with('type')
->willReturn($formBuilderInterfaceMock);
$dynamicContentType->buildForm($formBuilderInterfaceMock, $options);
}
/**
* @return array<string, array<string, array<string, mixed>>>
*/
private function getMockChoiceFields(): array
{
return [
'lead' => [
'email' => [
'label' => 'Email',
'properties' => ['type' => 'email'],
'object' => 'lead',
'operators' => [
'equals' => '=',
'not equal' => '!=',
'empty' => 'empty',
'not empty' => '!empty',
'like' => 'like',
'not like' => '!like',
'regexp' => 'regexp',
'not regexp' => '!regexp',
'starts with' => 'startsWith',
'ends with' => 'endsWith',
'contains' => 'contains',
],
],
'firstname' => [
'label' => 'First Name',
'properties' => ['type' => 'text'],
'object' => 'lead',
'operators' => [
'equals' => '=',
'not equal' => '!=',
'empty' => 'empty',
'not empty' => '!empty',
'like' => 'like',
'not like' => '!like',
'regexp' => 'regexp',
'not regexp' => '!regexp',
'starts with' => 'startsWith',
'ends with' => 'endsWith',
'contains' => 'contains',
],
],
'lastname' => [
'label' => 'Last Name',
'properties' => ['type' => 'text'],
'object' => 'lead',
'operators' => [
'equals' => '=',
'not equal' => '!=',
'empty' => 'empty',
'not empty' => '!empty',
'like' => 'like',
'not like' => '!like',
'regexp' => 'regexp',
'not regexp' => '!regexp',
'starts with' => 'startsWith',
'ends with' => 'endsWith',
'contains' => 'contains',
],
],
],
];
}
/**
* @return array<int, array<string, array<string,string|null>|string>>
*/
private function getMockCustomFieldList(): array
{
return [
[
'firstname' => [
'id' => '2',
'label' => 'First Name',
'alias' => 'firstname',
'type' => 'text',
'group' => 'core',
'object' => 'lead',
'is_fixed' => '1',
'properties' => 'a:0:{}',
'default_value' => null,
],
'lastname' => [
'id' => '3',
'label' => 'Last Name',
'alias' => 'lastname',
'type' => 'text',
'group' => 'core',
'object' => 'lead',
'is_fixed' => '1',
'properties' => 'a:0:{}',
'default_value' => null,
],
'email' => [
'id' => '6',
'label' => 'Email',
'alias' => 'email',
'type' => 'email',
'group' => 'core',
'object' => 'lead',
'is_fixed' => '1',
'properties' => 'a:0:{}',
'default_value' => null,
],
],
[
'firstname' => 'firstname',
'lastname' => 'lastname',
'email' => 'email',
],
];
}
/**
* @return array<int, array<string, string>>
*/
private function getMockTagList(): array
{
return [
[
'value' => '1',
'label' => 't1',
],
[
'value' => '2',
'label' => 't2',
],
[
'value' => '3',
'label' => 't3',
],
];
}
}

View File

@@ -0,0 +1,371 @@
<?php
namespace Mautic\DynamicContentBundle\Tests\EventListener;
use Mautic\AssetBundle\Helper\TokenHelper as AssetTokenHelper;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\EventListener\DynamicContentSubscriber;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\FormBundle\Helper\TokenHelper as FormTokenHelper;
use Mautic\LeadBundle\Entity\CompanyLeadRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\Helper\TokenHelper as PageTokenHelper;
use Mautic\PageBundle\Model\TrackableModel;
use MauticPlugin\MauticFocusBundle\Helper\TokenHelper as FocusTokenHelper;
use PHPUnit\Framework\MockObject\MockObject;
class DynamicContentSubscriberTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject|TrackableModel
*/
private MockObject $trackableModel;
/**
* @var MockObject|PageTokenHelper
*/
private MockObject $pageTokenHelper;
/**
* @var MockObject|AssetTokenHelper
*/
private MockObject $assetTokenHelper;
/**
* @var MockObject|FormTokenHelper
*/
private MockObject $formTokenHelper;
/**
* @var MockObject|FocusTokenHelper
*/
private MockObject $focusTokenHelper;
/**
* @var MockObject|AuditLogModel
*/
private MockObject $auditLogModel;
/**
* @var MockObject|DynamicContentHelper
*/
private MockObject $dynamicContentHelper;
/**
* @var MockObject|DynamicContentModel
*/
private MockObject $dynamicContentModel;
/**
* @var MockObject|CorePermissions
*/
private MockObject $security;
/**
* @var MockObject|ContactTracker
*/
private MockObject $contactTracker;
private \PHPUnit\Framework\MockObject\MockObject|CompanyLeadRepository $companyLeadRepositoryMock;
private DynamicContentSubscriber $subscriber;
/**
* @var CompanyModel|(CompanyModel&MockObject)|MockObject
*/
private MockObject $companyModel;
protected function setUp(): void
{
parent::setUp();
$this->trackableModel = $this->createMock(TrackableModel::class);
$this->pageTokenHelper = $this->createMock(PageTokenHelper::class);
$this->assetTokenHelper = $this->createMock(AssetTokenHelper::class);
$this->formTokenHelper = $this->createMock(FormTokenHelper::class);
$this->focusTokenHelper = $this->createMock(FocusTokenHelper::class);
$this->auditLogModel = $this->createMock(AuditLogModel::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->dynamicContentHelper = $this->createMock(DynamicContentHelper::class);
$this->dynamicContentModel = $this->createMock(DynamicContentModel::class);
$this->security = $this->createMock(CorePermissions::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->companyModel = $this->createMock(CompanyModel::class);
$this->companyLeadRepositoryMock = $this->createMock(CompanyLeadRepository::class);
$this->subscriber = new DynamicContentSubscriber(
$this->trackableModel,
$this->pageTokenHelper,
$this->assetTokenHelper,
$this->formTokenHelper,
$this->focusTokenHelper,
$this->auditLogModel,
$this->dynamicContentHelper,
$this->dynamicContentModel,
$this->security,
$this->contactTracker,
$this->companyModel
);
}
/**
* This test is ensuring this error won't happen again:.
*
* DOMDocumentFragment::appendXML(): Entity: line 1: parser error : xmlParseEntityRef: no name
*
* It happens when there is an ampersand in the DWC content.
*/
public function testDecodeTokensWithAmpersandDataAttribute(): void
{
$content = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
<div data-slot="dwc" data-param-slot-name="test-token"></div>
</body>
</html>
HTML;
$expected = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
<a href="https://john.doe&son">Link</a>
</body>
</html>
HTML;
$dwcContent = '<a href="https://john.doe&son">Link</a>';
$event = $this->createMock(PageDisplayEvent::class);
$contact = new Lead();
$event->expects($this->once())
->method('getContent')
->willReturn($content);
$this->security->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$this->contactTracker->expects($this->once())
->method('getContact')
->willReturn($contact);
$this->dynamicContentHelper->expects($this->never())
->method('convertLeadToArray');
$this->dynamicContentHelper->expects($this->once())
->method('findDwcTokens')
->with($content, $contact)
->willReturn([]);
$this->dynamicContentHelper->expects($this->once())
->method('getDynamicContentForLead')
->with('test-token', $contact)
->willReturn($dwcContent);
$event->expects($this->once())
->method('setContent')
->with($expected);
$this->subscriber->decodeTokens($event);
}
/**
* This test is ensuring this error won't happen again:.
*
* DOMDocumentFragment::appendXML(): Entity: line 1: parser error : xmlParseEntityRef: no name
*
* It happens when there is an ampersand in the DWC content.
*/
public function testDecodeTokensWithAmpersandInlineDwc(): void
{
$content = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
{dwc=test-token}
</body>
</html>
HTML;
$expected = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
<a href="https://john.doe&son">Link</a>
</body>
</html>
HTML;
$dwcContent = '<a href="https://john.doe&son">Link</a>';
$event = $this->createMock(PageDisplayEvent::class);
$contact = new Lead();
$event->expects($this->once())
->method('getContent')
->willReturn($content);
$this->security->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$this->contactTracker->expects($this->once())
->method('getContact')
->willReturn($contact);
$this->dynamicContentHelper->expects($this->never())
->method('convertLeadToArray');
$this->dynamicContentHelper->expects($this->once())
->method('findDwcTokens')
->with($content, $contact)
->willReturn([
'{dwc=test-token}' => [
'content' => $dwcContent,
'filters' => [
[
'field' => 'email',
'operator' => '!empty',
'filter' => '',
'type' => 'email',
],
],
],
]);
$this->dynamicContentHelper->expects($this->never())
->method('getDynamicContentForLead');
$event->expects($this->once())
->method('setContent')
->with($expected);
$this->subscriber->decodeTokens($event);
}
public function testOnTokenReplacement(): void
{
$content = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
Company name : {contactfield=companyname}
Company Country : {contactfield=companycountry}
Company website : {contactfield=companywebsite}
</body>
</html>
HTML;
$expected = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
Company name : Doe Corp
Company Country : India
Company website : https://www.doe.corp
</body>
</html>
HTML;
$contact = $this->createMock(Lead::class);
$event = $this->createMock(TokenReplacementEvent::class);
$event
->expects($this->once())
->method('getContent')
->willReturn($content);
$event
->expects($this->once())
->method('getLead')
->willReturn($contact);
$event
->expects($this->once())
->method('getClickthrough')
->willReturn([
'slot' => 'slotOne',
'dynamic_content_id' => 1,
'lead' => 1,
]);
$contact
->expects($this->once())
->method('getProfileFields')
->willReturn([
'id' => 1,
'firstname' => 'John',
'lastname' => 'Doe',
'company' => 'Doe Corp',
'email' => 'john@doe.com',
]);
$this->companyModel
->expects($this->once())
->method('getCompanyLeadRepository')
->willReturn($this->companyLeadRepositoryMock);
$this->companyLeadRepositoryMock->expects($this->once())
->method('getPrimaryCompanyByLeadId')
->willReturn(
[
'id' => 1,
'companyname' => 'Doe Corp',
'companycountry' => 'India',
'companywebsite' => 'https://www.doe.corp',
'is_primary' => true,
]
);
$this->pageTokenHelper
->method('findPageTokens')
->willReturn([]);
$this->assetTokenHelper
->method('findAssetTokens')
->willReturn([]);
$this->formTokenHelper
->method('findFormTokens')
->willReturn([]);
$this->focusTokenHelper
->method('findFocusTokens')
->willReturn([]);
$this->trackableModel
->method('parseContentForTrackables')
->willReturn([
$content,
[],
]);
$dwc = new DynamicContent();
$dwc->setContent($content);
$this->dynamicContentModel
->expects($this->once())
->method('getEntity')
->willReturn($dwc);
$event->expects($this->once())
->method('setContent')
->with($expected);
$this->subscriber->onTokenReplacement($event);
}
}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Unit\Helper;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\EventDispatcher\EventDispatcher;
class DynamicContentHelperTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject&DynamicContentModel
*/
private MockObject $mockModel;
/**
* @var MockObject&RealTimeExecutioner
*/
private MockObject $realTimeExecutioner;
/**
* @var MockObject&EventDispatcher
*/
private MockObject $mockDispatcher;
/**
* @var MockObject&LeadModel
*/
private MockObject $leadModel;
private DynamicContentHelper $helper;
protected function setUp(): void
{
$this->mockModel = $this->createMock(DynamicContentModel::class);
$this->realTimeExecutioner = $this->createMock(RealTimeExecutioner::class);
$this->mockDispatcher = $this->createMock(EventDispatcher::class);
$this->leadModel = $this->createMock(LeadModel::class);
$this->helper = new DynamicContentHelper(
$this->mockModel,
$this->realTimeExecutioner,
$this->mockDispatcher,
$this->leadModel,
);
}
public function testGetDwcBySlotNameWithPublished(): void
{
$matcher = $this->exactly(2);
$this->mockModel->expects($matcher)
->method('getEntities')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame([
'filter' => [
'where' => [
[
'col' => 'e.slotName',
'expr' => 'eq',
'val' => 'test',
],
[
'col' => 'e.isPublished',
'expr' => 'eq',
'val' => 1,
],
],
],
'ignore_paginator' => true,
], $parameters[0]);
return ['some entity'];
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame([
'filter' => [
'where' => [
[
'col' => 'e.slotName',
'expr' => 'eq',
'val' => 'secondtest',
],
],
],
'ignore_paginator' => true,
], $parameters[0]);
return [];
}
});
// Only get published
$this->assertCount(1, $this->helper->getDwcsBySlotName('test', true));
// Get all
$this->assertCount(0, $this->helper->getDwcsBySlotName('secondtest'));
}
public function testGetDynamicContentSlotForLeadWithListenerFindingMatch(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
// Setting filter that is not known to Mautic, but is for a plugin.
$slot->setFilters([['field' => 'unicorn', 'type' => 'text', 'operator' => '=', 'filter' => 'magic']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(true);
$matcher = $this->exactly(2);
$this->mockDispatcher->expects($matcher)
->method('dispatch')->willReturnCallback(function (...$parameters) use ($matcher, $contact, $slot) {
if (1 === $matcher->numberOfInvocations()) {
$callback = function (ContactFiltersEvaluateEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getContact());
$this->assertSame($slot->getFilters(), $event->getFilters());
$event->setIsEvaluated(true);
$event->setIsMatched(true); // Match found in a subscriber.
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE, $parameters[1]);
}
if (2 === $matcher->numberOfInvocations()) {
$callback = function (TokenReplacementEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getLead());
$this->assertSame($slot->getContent(), $event->getContent());
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::TOKEN_REPLACEMENT, $parameters[1]);
}
return $parameters[0];
});
Assert::assertSame(
'<p>test</p>',
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
public function testGetDynamicContentSlotForLeadWithListenerNotFindingMatch(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
// Setting filter that is not known to Mautic, nor any plugin.
$slot->setFilters([['field' => 'unicorn', 'type' => 'text', 'operator' => '=', 'filter' => 'magic']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(true);
$matcher = $this->once();
$this->mockDispatcher->expects($matcher)
->method('dispatch')
->willReturnCallback(
function (...$parameters) use ($matcher, $contact, $slot) {
if (1 === $matcher->numberOfInvocations()) {
$callback = function (ContactFiltersEvaluateEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getContact());
$this->assertSame($slot->getFilters(), $event->getFilters());
// Match not found in any subscriber.
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE, $parameters[1]);
}
return $parameters[0];
}
);
Assert::assertSame(
'', // No content returned as the filter did not match anything.
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
public function testGetDynamicContentSlotForLeadWithNoListenerWithMatchingFilter(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
$slot->setFilters([['field' => 'email', 'type' => 'email', 'operator' => '=', 'filter' => 'ma@ka.t']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(false);
$matcher = $this->once();
$this->mockDispatcher->expects($matcher)
->method('dispatch')
->willReturnCallback(
function (...$parameters) use ($matcher, $contact, $slot) {
if (1 === $matcher->numberOfInvocations()) {
$callback = function (TokenReplacementEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getLead());
$this->assertSame($slot->getContent(), $event->getContent());
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::TOKEN_REPLACEMENT, $parameters[1]);
}
return $parameters[0];
}
);
Assert::assertSame(
'<p>test</p>',
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
public function testGetDynamicContentSlotForLeadWithNoListenerWithNotMatchingFilter(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
$slot->setFilters([['field' => 'email', 'type' => 'email', 'operator' => '=', 'filter' => 'uni@co.rn']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(false);
$this->mockDispatcher->expects($this->never())->method('dispatch');
Assert::assertSame(
'',
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Unit\Validator\Constraints;
use Mautic\DynamicContentBundle\Validator\Constraints\NoNesting;
use Mautic\DynamicContentBundle\Validator\Constraints\NoNestingValidator;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Context\ExecutionContext;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class NoNestingValidatorTest extends TestCase
{
private const TRANSLATED_MESSAGE = 'DWC tokens cannot be used within another DWC.';
private NoNesting $constraint;
private NoNestingValidator $validator;
private ExecutionContextInterface $context;
protected function setUp(): void
{
$this->constraint = new NoNesting();
$this->validator = new NoNestingValidator();
$this->context = $this->createContext();
$this->context->setConstraint($this->constraint);
$this->validator->initialize($this->context);
}
public function testValidateWithInvalidConstraint(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->expectExceptionMessage(sprintf('Expected argument of type "%s"', NoNesting::class));
$this->validator->validate('value', new NotBlank());
}
public function testValidateWithInvalidType(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->expectExceptionMessage('Expected argument of type "string", "stdClass" given');
$this->validator->validate(new \stdClass(), $this->constraint);
}
public function testValidateWithNull(): void
{
$this->validator->validate(null, $this->constraint);
Assert::assertCount(0, $this->context->getViolations(), 'No violation should be added for a null value.');
}
public function testValidateWithValidValue(): void
{
$this->validator->validate('Some valid value', $this->constraint);
Assert::assertCount(0, $this->context->getViolations(), 'No violation should be added for a valid value.');
}
public function testValidateWithInvalidValue(): void
{
$this->validator->validate('Some invalid value {dwc=some}', $this->constraint);
Assert::assertCount(1, $this->context->getViolations(), 'There should be one violation for an invalid value.');
Assert::assertSame(self::TRANSLATED_MESSAGE, $this->context->getViolations()->get(0)->getMessage());
}
private function createContext(): ExecutionContextInterface
{
$locale = 'en_US';
$validator = $this->createMock(ValidatorInterface::class);
$translator = new Translator($locale);
$translator->addLoader('array', new ArrayLoader());
$translator->addResource('array', [
'mautic.dynamicContent.no_nesting' => self::TRANSLATED_MESSAGE,
], $locale, 'validators');
return new ExecutionContext($validator, null, $translator, 'validators');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Mautic\DynamicContentBundle\Tests\Validator\Constraints;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\DynamicContentBundle\Validator\Constraints\SlotNameType;
use Mautic\DynamicContentBundle\Validator\Constraints\SlotNameTypeValidator;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class SlotNameTypeValidatorTest extends ConstraintValidatorTestCase
{
/**
* @var DynamicContentModel|MockObject
*/
private $dynamicContentModel;
protected function createValidator(): SlotNameTypeValidator
{
$this->dynamicContentModel = $this->createMock(DynamicContentModel::class);
return new SlotNameTypeValidator($this->dynamicContentModel);
}
public function testValidSlotNameType(): void
{
$dynamicContent = new DynamicContent();
$dynamicContent->setSlotName('slot1');
$dynamicContent->setType('html');
$dynamicContent->setIsCampaignBased(false);
$existingContent = new DynamicContent();
$existingContent->setSlotName('slot1');
$existingContent->setType('html');
$dynamicContent->setIsCampaignBased(false);
$this->dynamicContentModel->method('checkEntityBySlotName')->willReturn(false);
$this->validator->validate($dynamicContent, new SlotNameType());
$this->assertNoViolation();
}
public function testInvalidSlotNameType(): void
{
$dynamicContent = new DynamicContent();
$dynamicContent->setSlotName('slot1');
$dynamicContent->setType('text');
$dynamicContent->setIsCampaignBased(false);
$existingContent = new DynamicContent();
$existingContent->setSlotName('slot1');
$existingContent->setType('html');
$dynamicContent->setIsCampaignBased(false);
$this->dynamicContentModel->method('checkEntityBySlotName')->willReturn(true);
$constraint = new SlotNameType();
$this->validator->validate($dynamicContent, $constraint);
$this->buildViolation($constraint->message)
->atPath('property.path.type')
->assertRaised();
}
}