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,64 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
final class DeviceTrackingServiceClearCookiesTest extends MauticMysqlTestCase
{
/**
* @return array<string, array{bool}>
*/
public static function blockedTrackingCookieDataProvider(): array
{
return [
'with blocked tracking cookie' => [true],
'without blocked tracking cookie' => [false],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('blockedTrackingCookieDataProvider')]
public function testClearTrackingCookiesBehavior(bool $shouldClearCookies): void
{
$this->logoutUser();
$page = new Page();
$page->setIsPublished(true);
$page->setTitle('Test Page for Clear Tracking Cookies');
$page->setAlias('test-clear-cookies');
$page->setCustomHtml('<html><body><h1>Test Page</h1></body></html>');
$this->em->persist($page);
$this->em->flush();
if ($shouldClearCookies) {
$this->client->getCookieJar()->set(new \Symfony\Component\BrowserKit\Cookie('Blocked-Tracking', '1'));
}
$this->client->request(Request::METHOD_GET, '/test-clear-cookies');
$this->assertResponseIsSuccessful();
$deviceIdCookieCleared = false;
$mtcIdCookieCleared = false;
foreach ($this->client->getResponse()->headers->getCookies() as $cookie) {
// Check if tracking cookies are being deleted (empty value + past expiration)
$cookieIsDeleted = '' === $cookie->getValue() && $cookie->getExpiresTime() < time();
if ('mautic_device_id' === $cookie->getName() && $cookieIsDeleted) {
$deviceIdCookieCleared = true;
}
if ('mtc_id' === $cookie->getName() && $cookieIsDeleted) {
$mtcIdCookieCleared = true;
}
}
Assert::assertSame($shouldClearCookies, $deviceIdCookieCleared);
Assert::assertSame($shouldClearCookies, $mtcIdCookieCleared);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class NotFoundFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testCustom404Page(): void
{
// Create a custom 404 page:
$notFoundPage = new Page();
$notFoundPage->setTitle('404 Not Found');
$notFoundPage->setAlias('404-not-found');
$notFoundPage->setCustomHtml('<html><body>Custom 404 Not Found Page</body></html>');
$this->em->persist($notFoundPage);
$this->em->flush();
// Configure the 404 page:
$this->configParams['404_page'] = $notFoundPage->getId();
parent::setUpSymfony($this->configParams);
// Test the custom 404 page:
$crawler = $this->client->request(Request::METHOD_GET, '/page-that-does-not-exist');
Assert::assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
Assert::assertStringContainsString('Custom 404 Not Found Page', $crawler->text());
Assert::assertFalse($this->client->getResponse()->isRedirection(), 'The response should not be a redirect.');
Assert::assertSame('/page-that-does-not-exist', $this->client->getRequest()->getRequestUri(), 'The request URI should be the same as the original URI.');
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\PageBundle\Entity\Page;
use Mautic\ProjectBundle\Entity\Project;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class PageControllerFunctionalTest extends MauticMysqlTestCase
{
public function testPagePreview(): void
{
$segment = $this->createSegment();
$filter = [
[
'glue' => 'and',
'field' => 'leadlist',
'object' => 'lead',
'type' => 'leadlist',
'filter' => [$segment->getId()],
'display' => null,
'operator' => 'in',
],
];
$dynamicContent = $this->createDynamicContentWithSegmentFilter($filter);
$dynamicContentToken = sprintf('{dwc=%s}', $dynamicContent->getSlotName());
$page = $this->createPage($dynamicContentToken);
$this->client->request(Request::METHOD_GET, sprintf('/%s', $page->getAlias()));
$response = $this->client->getResponse();
$this->assertSame(200, $response->getStatusCode());
$this->assertStringContainsString('Test Html', $response->getContent());
}
private function createSegment(): LeadList
{
$segment = new LeadList();
$segment->setName('Segment 1');
$segment->setPublicName('Segment 1');
$segment->setAlias('segment_1');
$this->em->persist($segment);
$this->em->flush();
return $segment;
}
/**
* @param mixed[] $filters
*/
private function createDynamicContentWithSegmentFilter(array $filters = []): DynamicContent
{
$dynamicContent = new DynamicContent();
$dynamicContent->setName('DC 1');
$dynamicContent->setDescription('Customised value');
$dynamicContent->setFilters($filters);
$dynamicContent->setIsCampaignBased(false);
$dynamicContent->setSlotName('Segment1_Slot');
$this->em->persist($dynamicContent);
$this->em->flush();
return $dynamicContent;
}
private function createPage(string $token = ''): Page
{
$page = new Page();
$page->setIsPublished(true);
$page->setTitle('Page Title');
$page->setAlias('page-alias');
$page->setTemplate('blank');
$page->setCustomHtml('Test Html'.$token);
$this->em->persist($page);
$this->em->flush();
return $page;
}
public function testPageWithProject(): void
{
$page = $this->createPage();
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/pages/edit/'.$page->getId());
$form = $crawler->selectButton('Save')->form();
$form['page[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedPage = $this->em->find(Page::class, $page->getId());
$this->assertSame($project->getId(), $savedPage->getProjects()->first()->getId());
}
public function testPageWithNullCustomHtmlIsUpdated(): void
{
$page = new Page();
$page->setTitle('Page A');
$page->setAlias('page-a');
$page->setTemplate('mautic_code_mode');
$this->em->persist($page);
$this->em->flush();
$pageId = $page->getId();
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/edit/'.$pageId);
$buttonCrawler = $crawler->selectButton('Save & Close');
$form = $buttonCrawler->form();
$form['page[title]']->setValue('New Page');
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$this->em->clear();
Assert::assertEquals('New Page', $this->em->find(Page::class, $pageId)->getTitle());
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Redirect;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class PageControllerSqlRollbackFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testRedirectNotPersistClickthrough(): void
{
$lead = new Lead();
$lead->setEmail('test@example.com');
$this->em->persist($lead);
$this->em->flush();
$redirectUrl = 'https://mautic.org/';
$redirect = new Redirect();
$redirectHash = uniqid('', true);
$redirect->setRedirectId($redirectHash);
$redirect->setUrl($redirectUrl);
$this->em->persist($redirect);
$this->em->flush();
$email = new Email();
$email->setName('Test email');
$this->em->persist($email);
$this->em->flush();
$statHash = uniqid('', true);
$stat = new Stat();
$stat->setEmail($email);
$stat->setEmailAddress($lead->getEmail());
$stat->setDateSent(new \DateTime());
$stat->setLead($lead);
$stat->setTrackingHash($statHash);
$this->em->persist($stat);
$this->em->flush();
$ct = [
'source' => ['email', $email->getId()],
'email' => $email->getId(),
'stat' => $statHash,
'lead' => '1',
'channel' => ['email' => $email->getId()],
];
$encodedCt = base64_encode(serialize($ct));
$this->setUpSymfony($this->configParams);
$this->client->followRedirects(false);
$this->client->request(Request::METHOD_GET, "/r/{$redirectHash}?ct={$encodedCt}");
$response = $this->client->getResponse();
Assert::assertTrue($response->isRedirect($redirectUrl), (string) $response);
// Re-enable redirect following for subsequent tests.
$this->client->followRedirects();
$hitRepository = $this->em->getRepository(Hit::class);
/** @var Hit|null $hit */
$hit = $hitRepository->findOneBy(['lead' => $lead]);
Assert::assertNotNull($hit, 'A Hit entity should have been created.');
Assert::assertSame('email', $hit->getSource(), 'The hit source should be email.');
Assert::assertSame($email->getId(), $hit->getSourceId(), 'The hit source ID should be the email ID.');
Assert::assertSame($redirect->getId(), $hit->getRedirect()->getId(), 'The hit should be associated with the correct redirect.');
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Traits\ControllerTrait;
use Mautic\LeadBundle\Entity\UtmTag;
use Mautic\PageBundle\DataFixtures\ORM\LoadPageCategoryData;
use Mautic\PageBundle\DataFixtures\ORM\LoadPageData;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Model\PageModel;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PageControllerTest extends MauticMysqlTestCase
{
use ControllerTrait;
/**
* @var string
*/
private $prefix;
/**
* @var int
*/
private $id;
/**
* @throws \Exception
*/
protected function setUp(): void
{
parent::setUp();
$this->prefix = static::getContainer()->getParameter('mautic.db_table_prefix');
$pageData = [
'title' => 'Test Page',
'template' => 'blank',
];
$model = static::getContainer()->get('mautic.page.model.page');
$page = new Page();
$page->setTitle($pageData['title'])
->setTemplate($pageData['template']);
$model->saveEntity($page);
$this->id = $page->getId();
}
/**
* Index should return status code 200.
*/
public function testIndexAction(): void
{
$urlAlias = 'pages';
$routeAlias = 'page';
$column = 'dateModified';
$column2 = 'title';
$tableAlias = 'p.';
$this->getControllerColumnTests($urlAlias, $routeAlias, $column, $tableAlias, $column2);
}
public function testLandingPageTracking(): void
{
$this->logoutUser();
$this->connection->insert($this->prefix.'pages', [
'is_published' => true,
'date_added' => (new \DateTime())->format('Y-m-d H:i:s'),
'title' => 'Page:Page:LandingPageTracking',
'alias' => 'page-page-landingPageTracking',
'template' => 'blank',
'custom_html' => 'some content',
'hits' => 0,
'unique_hits' => 0,
'variant_hits' => 0,
'revision' => 0,
'lang' => 'en',
]);
$leadsBeforeTest = $this->connection->fetchAllAssociative('SELECT `id` FROM `'.$this->prefix.'leads`;');
$leadIdsBeforeTest = array_column($leadsBeforeTest, 'id');
$this->client->request('GET', '/page-page-landingPageTracking');
$this->assertEquals(200, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
$sql = 'SELECT `id` FROM `'.$this->prefix.'leads`';
if (!empty($leadIdsBeforeTest)) {
$sql .= ' WHERE `id` NOT IN ('.implode(',', $leadIdsBeforeTest).');';
}
$newLeads = $this->connection->fetchAllAssociative($sql);
$this->assertCount(1, $newLeads);
$leadId = reset($newLeads)['id'];
$leadEventLogs = $this->connection->fetchAllAssociative('
SELECT `id`, `action`
FROM `'.$this->prefix.'lead_event_log`
WHERE `lead_id` = :leadId
AND `bundle` = "page" AND `object` = "page";', ['leadId' => $leadId]
);
$this->assertCount(1, $leadEventLogs);
$this->assertSame('created_contact', reset($leadEventLogs)['action']);
}
/**
* Skipped for now.
*/
public function LandingPageTrackingSecondVisit(): void
{
$this->connection->insert($this->prefix.'pages', [
'is_published' => true,
'date_added' => (new \DateTime())->format('Y-m-d H:i:s'),
'title' => 'Page:Page:LandingPageTrackingSecondVisit',
'alias' => 'page-page-landingPageTrackingSecondVisit',
'template' => 'blank',
'hits' => 0,
'unique_hits' => 0,
'variant_hits' => 0,
'revision' => 0,
'lang' => 'en',
]);
$leadsBeforeTest = $this->connection->fetchAllAssociative('SELECT `id` FROM `'.$this->prefix.'leads`;');
$leadIdsBeforeTest = array_column($leadsBeforeTest, 'id');
$this->client->request('GET', '/page-page-landingPageTrackingSecondVisit');
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
$sql = 'SELECT `id` FROM `'.$this->prefix.'leads`';
if (!empty($leadIdsBeforeTest)) {
$sql .= ' WHERE `id` NOT IN ('.implode(',', $leadIdsBeforeTest).');';
}
$newLeadsAfterFirstVisit = $this->connection->fetchAllAssociative($sql);
$this->assertCount(1, $newLeadsAfterFirstVisit);
$leadId = reset($newLeadsAfterFirstVisit)['id'];
$eventLogsAfterFirstVisit = $this->connection->fetchAllAssociative('
SELECT `id`, `action`
FROM `'.$this->prefix.'lead_event_log`
WHERE `lead_id` = :leadId
AND `bundle` = "page" AND `object` = "page";', ['leadId' => $leadId]
);
$this->assertCount(1, $eventLogsAfterFirstVisit);
$this->assertSame('created_contact', reset($eventLogsAfterFirstVisit)['action']);
$this->client->request('GET', '/page-page-landingPageTrackingSecondVisit');
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
$eventLogsAfterSecondVisit = $this->connection->fetchAllAssociative('
SELECT `id`, `action`
FROM `'.$this->prefix.'lead_event_log`
WHERE `lead_id` = :leadId
AND `bundle` = "page" AND `object` = "page";', ['leadId' => $leadId]
);
$this->assertCount(1, $eventLogsAfterSecondVisit);
$this->assertSame(reset($eventLogsAfterFirstVisit)['id'], reset($eventLogsAfterSecondVisit)['id']);
}
/**
* Test tracking of a first visit with UTM Tags.
*/
public function testLandingPageWithUtmTracking(): void
{
$this->logoutUser();
$timestamp = \time();
$page = $this->createTestPage();
$this->client->request('GET', "/{$page->getAlias()}?utm_source=linkedin&utm_medium=social&utm_campaign=mautic&utm_content=".$timestamp);
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode(), $clientResponse->getContent());
$allUtmTags = $this->em->getRepository(UtmTag::class)->getEntities();
$this->assertNotCount(0, $allUtmTags);
foreach ($allUtmTags as $utmTag) {
$this->assertSame('linkedin', $utmTag->getUtmSource(), 'utm_source does not match');
$this->assertSame('social', $utmTag->getUtmMedium(), 'utm_medium does not match');
$this->assertSame('mautic', $utmTag->getUtmCampaign(), 'utm_campaign does not match');
$this->assertSame(strval($timestamp), $utmTag->getUtmContent(), 'utm_content does not match');
}
}
/**
* Create a page for testing.
*/
protected function createTestPage($pageParams = []): Page
{
$page = new Page();
$title = $pageParams['title'] ?? 'Page:Page:LandingPageTracking';
$alias = $pageParams['alias'] ?? 'page-page-landingPageTracking';
$isPublished = $pageParams['isPublished'] ?? true;
$template = $pageParams['template'] ?? 'blank';
$page->setTitle($title);
$page->setAlias($alias);
$page->setIsPublished($isPublished);
$page->setTemplate($template);
$page->setCustomHtml('some content');
$this->em->persist($page);
$this->em->flush();
return $page;
}
/*
* Get page's view.
*/
public function testViewActionPage(): void
{
$this->client->request('GET', '/s/pages/view/'.$this->id);
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$model = static::getContainer()->get('mautic.page.model.page');
$page = $model->getEntity($this->id);
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$this->assertStringContainsString($page->getTitle(), $clientResponseContent, 'The return must contain the title of page');
}
/**
* Get landing page's create page.
*/
public function testNewActionPage(): void
{
$this->client->request('GET', '/s/pages/new/');
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
}
/* Get landing page's submissions list */
public function testListLandingPageSubmissions(): void
{
$this->client->request('GET', 's/pages/results/'.$this->id);
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$model = static::getContainer()->get('mautic.page.model.page');
$page = $model->getEntity($this->id);
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
}
/**
* Only tests if an actual CSV file is returned.
*/
public function testCsvIsExportedCorrectly(): void
{
$this->loadFixtures([LoadPageCategoryData::class, LoadPageData::class]);
ob_start();
$this->client->request(Request::METHOD_GET, '/s/pages/results/'.$this->id.'/export');
$content = ob_get_contents();
ob_end_clean();
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$this->assertEquals($this->client->getInternalResponse()->getHeader('content-type'), 'text/csv; charset=UTF-8');
}
/**
* Only tests if an actual Excel file is returned.
*/
public function testExcelIsExportedCorrectly(): void
{
$this->loadFixtures([LoadPageCategoryData::class, LoadPageData::class]);
ob_start();
$this->client->request(Request::METHOD_GET, '/s/pages/results/'.$this->id.'/export/xlsx');
$content = ob_get_contents();
ob_end_clean();
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$this->assertEquals($this->client->getInternalResponse()->getHeader('content-type'), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
}
/**
* Only tests if an actual HTML file is returned.
*/
public function testHTMLIsExportedCorrectly(): void
{
$this->loadFixtures([LoadPageCategoryData::class, LoadPageData::class]);
ob_start();
$this->client->request(Request::METHOD_GET, '/s/pages/results/'.$this->id.'/export/html');
$content = ob_get_contents();
ob_end_clean();
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$this->assertEquals($this->client->getInternalResponse()->getHeader('content-type'), 'text/html; charset=UTF-8');
}
public function testSavePageAliasWithUnderscores(): void
{
/** @var PageModel $pageModel */
$pageModel = static::getContainer()->get('mautic.page.model.page');
$parentPage = new Page();
$parentPage->setTitle('This is My Page');
$parentPage->setAlias('This_Is_My_Page');
$parentPage->setTemplate('blank');
$parentPage->setCustomHtml('This is My Page');
$pageModel->saveEntity($parentPage);
$this->client->request(Request::METHOD_GET, '/this_is_my_page');
$response = $this->client->getResponse();
Assert::assertTrue($response->isOk());
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\PageDraft;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
final class PageDraftFunctionalTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->configParams['page_draft_enabled'] = 'testPageDraftNotConfigured' !== $this->name();
parent::setUp();
}
public function testPageDraftNotConfigured(): void
{
$page = $this->createNewPage();
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/edit/{$page->getId()}");
Assert::assertEquals(0, $crawler->selectButton('Save as Draft')->count());
Assert::assertEquals(0, $crawler->selectButton('Apply Draft')->count());
Assert::assertEquals(0, $crawler->selectButton('Discard Draft')->count());
}
public function testPageDraftConfigured(): void
{
$page = $this->createNewPage();
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/edit/{$page->getId()}");
Assert::assertEquals(1, $crawler->selectButton('Save as Draft')->count());
Assert::assertEquals(0, $crawler->selectButton('Apply Draft')->count());
Assert::assertEquals(0, $crawler->selectButton('Discard Draft')->count());
}
public function testCheckDraftInList(): void
{
$page = $this->createNewPage();
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages');
$this->assertStringNotContainsString('Has Draft', $crawler->filter('#app-content a[href="/s/pages/view/'.$page->getId().'"]')->html());
$this->saveDraft($page);
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages');
$this->assertStringContainsString('Has Draft', $crawler->filter('#app-content a[href="/s/pages/view/'.$page->getId().'"]')->html());
}
public function testPreviewDraft(): void
{
$page = $this->createNewPage();
$this->saveDraft($page);
$crawler = $this->client->request(Request::METHOD_GET, "/page/preview/{$page->getId()}");
$this->assertEquals('Test html', $crawler->filter('body')->text());
$crawler = $this->client->request(Request::METHOD_GET, "/page/preview/{$page->getId()}/draft");
$this->assertEquals('Test html Draft', $crawler->filter('body')->text());
}
public function testSaveDraftAndApplyDraft(): void
{
$page = $this->createNewPage();
$this->saveDraft($page);
$this->applyDraft($page);
}
public function testDiscardDraft(): void
{
$page = $this->createNewPage();
$this->saveDraft($page);
$this->discardDraft($page);
}
private function applyDraft(Page $page): void
{
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/edit/{$page->getId()}");
$form = $crawler->selectButton('Apply Draft')->form();
$this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
$pageDraft = $this->em->getRepository(PageDraft::class)->findOneBy(['page' => $page]);
Assert::assertNull($pageDraft);
Assert::assertSame('Test html Draft', $page->getCustomHtml());
}
private function discardDraft(Page $page): void
{
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/edit/{$page->getId()}");
$form = $crawler->selectButton('Discard Draft')->form();
$this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
$pageDraft = $this->em->getRepository(PageDraft::class)->findOneBy(['page' => $page]);
Assert::assertNull($pageDraft);
Assert::assertSame('Test html', $page->getCustomHtml());
}
private function saveDraft(Page $page): void
{
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/edit/{$page->getId()}");
$form = $crawler->selectButton('Save as Draft')->form();
$form['page[customHtml]'] = 'Test html Draft';
$this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
$pageDraft = $this->em->getRepository(PageDraft::class)->findOneBy(['page' => $page]);
Assert::assertEquals('Test html Draft', $pageDraft->getHtml());
Assert::assertSame('Test html', $page->getCustomHtml());
}
private function createNewPage(): Page
{
$pageObject = new Page();
$pageObject->setIsPublished(true);
$pageObject->setDateAdded(new \DateTime());
$pageObject->setTitle('Page Test');
$pageObject->setAlias('Page Test');
$pageObject->setTemplate('blank');
$pageObject->setCustomHtml('Test html');
$pageObject->setLanguage('en');
$this->em->persist($pageObject);
$this->em->flush();
return $pageObject;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\PageBundle\Entity\Page;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class PageProjectSearchFunctionalTest 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');
$pageAlpha = $this->createPage('Page Alpha');
$pageBeta = $this->createPage('Page Beta');
$this->createPage('Page Gamma');
$this->createPage('Page Delta');
$pageAlpha->addProject($projectOne);
$pageAlpha->addProject($projectTwo);
$pageBeta->addProject($projectTwo);
$pageBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/pages', '/s/pages']);
}
/**
* @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' => ['Page Alpha', 'Page Beta'],
'unexpectedEntities' => ['Page Gamma', 'Page Delta'],
];
yield 'search by one project AND page name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Page Beta'],
'unexpectedEntities' => ['Page Alpha', 'Page Gamma', 'Page Delta'],
];
yield 'search by one project OR page name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Page Alpha', 'Page Beta', 'Page Gamma'],
'unexpectedEntities' => ['Page Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Page Gamma', 'Page Delta'],
'unexpectedEntities' => ['Page Alpha', 'Page Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Page Beta'],
'unexpectedEntities' => ['Page Alpha', 'Page Gamma', 'Page Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Page Gamma', 'Page Delta'],
'unexpectedEntities' => ['Page Alpha', 'Page Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Page Alpha', 'Page Beta'],
'unexpectedEntities' => ['Page Gamma', 'Page Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Page Alpha', 'Page Gamma', 'Page Delta'],
'unexpectedEntities' => ['Page Beta'],
];
}
private function createPage(string $name): Page
{
$page = new Page();
$page->setTitle($name);
$page->setAlias($name);
$this->em->persist($page);
return $page;
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Page;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PreviewFunctionalTest extends MauticMysqlTestCase
{
public function testPreviewPageWithContact(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$lead = $this->createLead();
$dynamicContent = $this->createDynamicContent($lead);
$defaultContent = 'Default web content';
// Create non public landing page.
$page = $this->createPage($dynamicContent, $defaultContent, true, false);
$this->em->flush();
$this->em->clear();
$url = "/page/preview/{$page->getId()}";
// Anonymous visitor is not allowed to access preview if not public
$this->logoutUser();
$this->client->request(Request::METHOD_GET, $url);
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
$this->loginUser($user);
// Admin user is allowed to access preview
$this->assertPageContent($url, $defaultContent);
// Check DWC replacement for the given lead
$this->assertPageContent("{$url}?contactId={$lead->getId()}", $dynamicContent->getContent());
// Check there is no DWC replacement for a non-existent lead
$this->assertPageContent("{$url}?contactId=987", $defaultContent);
$this->logoutUser();
// Anonymous visitor is not allowed to access preview
$this->client->request(Request::METHOD_GET, $url);
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testPreviewPageUrlIsValid(): void
{
$page = $this->createPage();
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check for correct preview URL.
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/view/'.$pageId);
self::assertStringContainsString('/page/preview/'.$pageId, $crawler->filter('#content_preview_url')->attr('value'));
}
public function testPreviewPagePublicToggle(): void
{
$page = $this->createPage();
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check for public preview ON.
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/view/'.$pageId);
$toggleElem = $crawler->filter('i.ri-toggle-fill');
self::assertEquals(1, $toggleElem->count());
// Toggle public preview.
$parameters = [
'action' => 'togglePublishStatus',
'model' => 'page',
'id' => $pageId,
'customToggle' => 'publicPreview',
];
$this->client->request(Request::METHOD_POST, '/s/ajax', $parameters);
// Check for public preview OFF.
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/view/'.$pageId);
$toggleElem = $crawler->filter('i.ri-toggle-line');
self::assertEquals(1, $toggleElem->count());
// Create landing page with public preview OFF.
$page = $this->createPage(null, '', true, false);
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check for public preview OFF.
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/view/'.$pageId);
self::assertEquals(1, $crawler->filter('i.ri-toggle-line')->count());
// Toggle public preview.
$parameters['id'] = $pageId;
$this->client->request(Request::METHOD_POST, '/s/ajax', $parameters);
// Check for public preview ON.
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/view/'.$pageId);
$toggleElem = $crawler->filter('i.ri-toggle-fill');
self::assertEquals(1, $toggleElem->count());
}
public function testPreviewPageWithPublishAndPublicOptions(): void
{
$page = $this->createPage();
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check for public preview ON.
$this->client->request(Request::METHOD_GET, '/s/logout');
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/'.$pageId);
self::assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
self::assertEquals('Hello', $crawler->filter('body')->text());
// Create landing page with public preview OFF.
$page = $this->createPage(null, '', true, false);
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check public preview without login.
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/'.$pageId);
self::assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString(
'Unauthorized access to requested URL: /page/preview/'.$pageId,
$crawler->text()
);
// Create page with publish OFF.
$page = $this->createPage(null, '', false);
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check for public preview ON.
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/'.$pageId);
self::assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString(
'Unauthorized access to requested URL: /page/preview/'.$pageId,
$crawler->text()
);
// Create landing page with publish and public preview OFF.
$page = $this->createPage(null, '', false, false);
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check for public preview ON.
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/'.$pageId);
self::assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString(
'Unauthorized access to requested URL: /page/preview/'.$pageId,
$crawler->text()
);
}
public function testPreviewPageNotFound(): void
{
// Check for non existing landing page preview.
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/20000');
self::assertEquals(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString('404 Not Found', $crawler->text());
}
public function testPreviewPageAccess(): void
{
// Create non published, non public landing page.
$page = $this->createPage(null, '', false, false);
$this->em->flush();
$this->em->clear();
$pageId = $page->getId();
// Check public preview with login.
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$this->loginUser($user);
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/'.$pageId);
self::assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
self::assertEquals('Hello', $crawler->filter('body')->text());
// Check public preview without login.
$this->client->request(Request::METHOD_GET, '/s/logout');
$crawler = $this->client->request(Request::METHOD_GET, '/page/preview/'.$pageId);
self::assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString(
'Unauthorized access to requested URL: /page/preview/'.$pageId,
$crawler->text()
);
// Check public preview access without permissions
$this->loginUser($user);
$security = $this->createMock(CorePermissions::class);
$security->method('isAnonymous')->willReturn(false);
$security->method('hasEntityAccess')->with(
'page:pages:viewown',
'page:pages:viewother',
$page->getCreatedBy()
)->willReturn(false);
$this->getContainer()->set('mautic.security', $security);
self::assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString(
'Unauthorized access to requested URL: /page/preview/'.$pageId,
$crawler->text()
);
}
private function assertPageContent(string $url, string $expectedContent): void
{
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
self::assertSame($expectedContent, $crawler->filter('body')->text());
}
private function createPage(
?DynamicContent $dynamicContent = null,
string $defaultContent = '',
bool $isPublished = true,
bool $publicPreview = true,
): Page {
if (null === $dynamicContent) {
$customHtml = '<html lang="en"><body>Hello</body></html>';
} else {
$customHtml = sprintf('<div data-slot="dwc" data-param-slot-name="%s"><span>%s</span></div>', $dynamicContent->getSlotName(), $defaultContent);
}
$page = new Page();
$page->setIsPublished($isPublished);
$page->setDateAdded(new \DateTime());
$page->setTitle('Preview settings test - main page');
$page->setAlias('page-main');
$page->setTemplate('Blank');
$page->setCustomHtml($customHtml);
$page->setPublicPreview($publicPreview);
$this->em->persist($page);
return $page;
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setEmail('test@domain.tld');
$this->em->persist($lead);
return $lead;
}
private function createDynamicContent(Lead $lead): DynamicContent
{
$dynamicContent = new DynamicContent();
$dynamicContent->setName('Test DWC');
$dynamicContent->setIsCampaignBased(false);
$dynamicContent->setContent('DWC content');
$dynamicContent->setSlotName('test');
$dynamicContent->setFilters([
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'filter' => $lead->getEmail(),
'display' => null,
'operator' => '=',
],
]);
$this->em->persist($dynamicContent);
return $dynamicContent;
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Page;
use Symfony\Component\HttpFoundation\Request;
class PreviewSettingsFunctionalTest extends MauticMysqlTestCase
{
public function testPreviewSettingsAllEnabled(): void
{
$pageMain = new Page();
$pageMain->setIsPublished(true);
$pageMain->setDateAdded(new \DateTime());
$pageMain->setTitle('Preview settings test - main page');
$pageMain->setAlias('page-main');
$pageMain->setTemplate('Blank');
$pageMain->setCustomHtml('Test Html');
$pageMain->setLanguage('en');
$this->em->persist($pageMain);
$this->em->flush();
$mainPageId = $pageMain->getId();
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages');
self::assertStringContainsString($pageMain->getTitle(), $crawler->text());
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/view/{$mainPageId}");
// Translation choice is not visible
self::assertCount(
0,
$crawler->filterXPath('//*[@id="content_preview_settings_translation"]')
);
// Variant choice is not visible
self::assertCount(
0,
$crawler->filterXPath('//*[@id="content_preview_settings_variant"]')
);
// Contact lookup is not visible
self::assertCount(
1,
$crawler->filterXPath('//*[@id="content_preview_settings_contact"]')
);
$pageTranslated = new Page();
$pageTranslated->setIsPublished(true);
$pageTranslated->setDateAdded(new \DateTime());
$pageTranslated->setTitle('Preview settings test - NL translation');
$pageTranslated->setAlias('page-trans-nl');
$pageTranslated->setTemplate('Blank');
$pageTranslated->setCustomHtml('Test Html');
$pageTranslated->setLanguage('nl_CW');
// Add translation relationship to main page
$pageMain->addTranslationChild($pageTranslated);
$pageTranslated->setTranslationParent($pageMain);
$pageVariant = new Page();
$pageVariant->setIsPublished(true);
$pageVariant->setDateAdded(new \DateTime());
$pageVariant->setTitle('Preview settings test - B variant');
$pageVariant->setAlias('page-variant-b');
$pageVariant->setTemplate('Blank');
$pageVariant->setCustomHtml('Test Html');
$pageVariant->setLanguage('en');
// Add variant relationship to main page
$pageMain->addVariantChild($pageVariant);
$this->em->persist($pageMain);
$this->em->persist($pageTranslated);
$this->em->persist($pageVariant);
$this->em->flush();
$crawler = $this->client->request(Request::METHOD_GET, "/s/pages/view/{$mainPageId}");
// Translation choice is visible
self::assertCount(
1,
$crawler->filterXPath('//*[@id="content_preview_settings_translation"]')
);
self::assertCount(
1,
$crawler->filterXPath('//*[@id="content_preview_settings_translation"]/option[@value="'.$pageTranslated->getId().'"]')
);
// Variant choice is visible
self::assertCount(
1,
$crawler->filterXPath('//*[@id="content_preview_settings_variant"]')
);
self::assertCount(
1,
$crawler->filterXPath('//*[@id="content_preview_settings_variant"]/option[@value="'.$pageVariant->getId().'"]')
);
// Contact lookup is visible
self::assertCount(
1,
$crawler->filterXPath('//*[@id="content_preview_settings_contact"]')
);
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Tag;
use Mautic\PageBundle\Entity\Page;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class PublicControllerFunctionalTest extends MauticMysqlTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('xssPayloadsProvider')]
public function testContactTrackingTagsXss(string $payload, ?string $expectedSanitized): void
{
$this->logoutUser();
$page = new Page();
$page->setIsPublished(true);
$page->setTitle('XSS Test');
$page->setAlias('xss-test');
$page->setCustomHtml('xss-test');
$this->em->persist($page);
$this->em->flush();
$encodedPayload = urlencode($payload);
$this->client->request(Request::METHOD_GET, "/xss-test?tags={$encodedPayload}");
Assert::assertTrue($this->client->getResponse()->isOk());
$tagRepository = $this->em->getRepository(Tag::class);
$tags = $tagRepository->findAll();
if ($expectedSanitized) {
// Assert that a tag was created
Assert::assertCount(1, $tags);
// Get the created tag
$tag = $tags[0];
// Assert that the tag name does not contain the malicious script
Assert::assertStringNotContainsString('<script>', $tag->getTag());
Assert::assertStringNotContainsString('</script>', $tag->getTag());
// Assert that the tag name has been properly sanitized
Assert::assertEquals($expectedSanitized, $tag->getTag());
} else {
// Assert that a tag was NOT created
Assert::assertCount(0, $tags);
}
// Check the response content to ensure no script is present
$content = $this->client->getResponse()->getContent();
Assert::assertStringNotContainsString($payload, $content);
}
/**
* @return array<string, array<int, string|null>>
*/
public static function xssPayloadsProvider(): array
{
return [
'Basic script tag' => [
'<script>alert(1)</script>',
'alert(1)',
],
'Script tag with attributes' => [
'<script src="http://example.com/evil.js"></script>',
null,
],
'Encoded script tag' => [
'&#60;script&#62;alert(1)&#60;/script&#62;',
'alert(1)',
],
'On-event handler' => [
'<img src="x" onerror="alert(1)">',
null,
],
'JavaScript protocol in URL' => [
'<a href="javascript:alert(1)">Click me</a>',
'Click me',
],
'SVG with embedded script' => [
'<svg><script>alert(1)</script></svg>',
'alert(1)',
],
'CSS expression' => [
'<div style="background:url(javascript:alert(1))">',
null,
],
'Malformed tag' => [
'<img """><script>alert("XSS")</script>"<',
'alert("XSS")"',
],
'Malformed tag2' => [
'<IMG SRC="jav&#x09;ascript:alert(\'XSS\');">',
null,
],
'Unicode escape' => [
'<script>\u0061lert(1)</script>',
'\u0061lert(1)',
],
];
}
public function testMtcEventCompanyXss(): void
{
$this->logoutUser();
$this->client->request('POST', '/mtc/event', [
'page_url' => 'https://example.com?Company=%3Cimg+src+onerror%3Dalert%28%27Company%27%29%3E',
]);
$this->assertResponseIsSuccessful();
$this->loginUser($this->em->getRepository(User::class)->findOneBy(['username' => 'admin']));
$response = json_decode($this->client->getResponse()->getContent(), true);
$this->client->request('GET', sprintf('/s/contacts/view/%d', $response['id']));
$this->assertResponseIsSuccessful();
$content = $this->client->getResponse()->getContent();
Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content);
$crawler = $this->client->request('GET', sprintf('/s/contacts/edit/%d', $response['id']));
$this->assertResponseIsSuccessful();
$content = $this->client->getResponse()->getContent();
Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content);
$buttonCrawlerNode = $crawler->selectButton('Save & Close');
Assert::assertCount(1, $buttonCrawlerNode, $crawler->html());
$form = $buttonCrawlerNode->form();
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$content = $this->client->getResponse()->getContent();
Assert::assertStringNotContainsString('<img src onerror=alert(\'Company\')>', $content);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\Redirect;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PublicControllerRedirectTest extends MauticMysqlTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('redirectTypeOptions')]
public function testValidationRedirectWithoutUrl(string $redirectUrl, string $expectedMessage): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/pages/new');
$saveButton = $crawler->selectButton('Save');
$form = $saveButton->form();
$form['page[title]']->setValue('Redirect test');
$form['page[redirectType]']->setValue((string) Response::HTTP_MOVED_PERMANENTLY);
$form['page[redirectUrl]']->setValue($redirectUrl);
$form['page[template]']->setValue('mautic_code_mode');
$this->client->submit($form);
Assert::assertStringContainsString($expectedMessage, $this->client->getResponse()->getContent());
}
/**
* @return iterable<string, array{string, string}>
*/
public static function redirectTypeOptions(): iterable
{
yield 'redirect set, empty redirect URL' => ['', 'A value is required.'];
yield 'redirect set, invalid redirect URL' => ['invalid.url', 'This value is not a valid URL.'];
yield 'redirect set, valid redirect URL' => ['https://valid.url', 'Edit Page - Redirect test'];
}
public function testCreateRedirectWithNoUrlForExistingPages(): void
{
$page = new Page();
$page->setTitle('Page A');
$page->setAlias('page-a');
$page->setIsPublished(false);
$page->setRedirectType((string) Response::HTTP_MOVED_PERMANENTLY);
$this->em->persist($page);
$this->em->flush();
$this->logoutUser();
$this->client->request(Request::METHOD_GET, '/page-a');
Assert::assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
}
public function testRedirectWithSpacesInQuery(): void
{
$url = 'https://google.com?q=this%20has%20spaces';
$redirect = new Redirect();
$redirect->setUrl($url);
$redirect->setRedirectId('57cf5a66a9f9414f301082cf0');
$this->em->persist($redirect);
$this->em->flush();
$this->client->followRedirects(false);
$this->client->request(Request::METHOD_GET, sprintf('/r/%s', $redirect->getRedirectId()));
$response = $this->client->getResponse();
\assert($response instanceof RedirectResponse);
Assert::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
Assert::assertSame($url, $response->getTargetUrl(), 'The spaces in the query part must not be encoded with plus signs.');
}
}

View File

@@ -0,0 +1,702 @@
<?php
namespace Mautic\PageBundle\Tests\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Exception\InvalidDecodedStringException;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\ThemeHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\CoreBundle\Twig\Helper\AnalyticsHelper;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\ContactRequestHelper;
use Mautic\LeadBundle\Helper\PrimaryCompanyHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Mautic\PageBundle\Controller\PublicController;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Event\TrackingEvent;
use Mautic\PageBundle\Helper\TrackingHelper;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\Model\Tracking404Model;
use Mautic\PageBundle\PageEvents;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Asset\Packages;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Router;
use Symfony\Component\Routing\RouterInterface;
#[\PHPUnit\Framework\Attributes\CoversClass(TrackingEvent::class)]
class PublicControllerTest extends MauticMysqlTestCase
{
/**
* @var MockObject|Container
*/
private MockObject $internalContainer;
/**
* @var \Psr\Log\LoggerInterface
*/
private MockObject $logger;
/**
* @var ModelFactory<object>&MockObject
*/
private MockObject $modelFactory;
/**
* @var RedirectModel
*/
private MockObject $redirectModel;
/**
* @var Redirect
*/
private MockObject $redirect;
private Request $request;
/**
* @var IpLookupHelper
*/
private MockObject $ipLookupHelper;
/**
* @var IpAddress
*/
private MockObject $ipAddress;
/**
* @var LeadModel
*/
private MockObject $leadModel;
/**
* @var PageModel
*/
private MockObject $pageModel;
/**
* @var PrimaryCompanyHelper
*/
private MockObject $primaryCompanyHelper;
/**
* @var ContactRequestHelper&MockObject
*/
private MockObject $contactRequestHelper;
protected function setUp(): void
{
$this->request = new Request();
$this->internalContainer = $this->createMock(Container::class);
$this->logger = $this->createMock(\Psr\Log\LoggerInterface::class);
$this->modelFactory = $this->createMock(ModelFactory::class);
$this->redirectModel = $this->createMock(RedirectModel::class);
$this->redirect = $this->createMock(Redirect::class);
$this->ipLookupHelper = $this->createMock(IpLookupHelper::class);
$this->ipAddress = $this->createMock(IpAddress::class);
$this->leadModel = $this->createMock(LeadModel::class);
$this->pageModel = $this->createMock(PageModel::class);
$this->primaryCompanyHelper = $this->createMock(PrimaryCompanyHelper::class);
$this->contactRequestHelper = $this->createMock(ContactRequestHelper::class);
parent::setUp();
}
/**
* Test that the appropriate variant is displayed based on hit counts and variant weights.
*/
public function testVariantPageWeightsAreAppropriate(): void
{
// Each of these should return the one with the greatest weight deficit based on
// A = 50%
// B = 25%
// C = 25%
// A = 0/50; B = 0/25; C = 0/25
$this->assertEquals('pageA', $this->getVariantContent(0, 0, 0));
// A = 100/50; B = 0/25; C = 0/25
$this->assertEquals('pageB', $this->getVariantContent(1, 0, 0));
// A = 50/50; B = 50/25; C = 0/25;
$this->assertEquals('pageC', $this->getVariantContent(1, 1, 0));
// A = 33/50; B = 33/25; C = 33/25;
$this->assertEquals('pageA', $this->getVariantContent(1, 1, 1));
// A = 66/50; B = 33/25; C = 0/25
$this->assertEquals('pageC', $this->getVariantContent(2, 1, 0));
// A = 50/50; B = 25/25; C = 25/25
$this->assertEquals('pageA', $this->getVariantContent(2, 1, 1));
// A = 33/50; B = 66/50; C = 0/25
$this->assertEquals('pageC', $this->getVariantContent(1, 2, 0));
// A = 25/50; B = 50/50; C = 25/25
$this->assertEquals('pageA', $this->getVariantContent(1, 2, 1));
// A = 55/50; B = 18/25; C = 27/25
$this->assertEquals('pageB', $this->getVariantContent(6, 2, 3));
// A = 50/50; B = 25/25; C = 25/25
$this->assertEquals('pageA', $this->getVariantContent(6, 3, 3));
}
/**
* @return string
*/
private function getVariantContent($aCount, $bCount, $cCount)
{
$pageEntityB = $this->createMock(Page::class);
$pageEntityB->method('getId')
->willReturn(2);
$pageEntityB->method('isPublished')
->willReturn(true);
$pageEntityB->method('getVariantHits')
->willReturn($bCount);
$pageEntityB->method('getTranslations')
->willReturn([]);
$pageEntityB->method('isTranslation')
->willReturn(false);
$pageEntityB->method('getContent')
->willReturn(null);
$pageEntityB->method('getCustomHtml')
->willReturn('pageB');
$pageEntityB->method('getVariantSettings')
->willReturn(['weight' => '25']);
$pageEntityC = $this->createMock(Page::class);
$pageEntityC->method('getId')
->willReturn(3);
$pageEntityC->method('isPublished')
->willReturn(true);
$pageEntityC->method('getVariantHits')
->willReturn($cCount);
$pageEntityC->method('getTranslations')
->willReturn([]);
$pageEntityC->method('isTranslation')
->willReturn(false);
$pageEntityC->method('getContent')
->willReturn(null);
$pageEntityC->method('getCustomHtml')
->willReturn('pageC');
$pageEntityC->method('getVariantSettings')
->willReturn(['weight' => '25']);
$pageEntityA = $this->createMock(Page::class);
$pageEntityA->method('getId')
->willReturn(1);
$pageEntityA->method('isPublished')
->willReturn(true);
$pageEntityA->method('getVariants')
->willReturn([$pageEntityA, [2 => $pageEntityB, 3 => $pageEntityC]]);
$pageEntityA->method('getVariantHits')
->willReturn($aCount);
$pageEntityA->method('getTranslations')
->willReturn([]);
$pageEntityA->method('isTranslation')
->willReturn(false);
$pageEntityA->method('getContent')
->willReturn(null);
$pageEntityA->method('getCustomHtml')
->willReturn('pageA');
$pageEntityA->method('getVariantSettings')
->willReturn(['weight' => '50']);
$cookieHelper = $this->createMock(CookieHelper::class);
/** @var Packages&MockObject $packagesMock */
$packagesMock = $this->createMock(Packages::class);
/** @var CoreParametersHelper&MockObject $coreParametersHelper */
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$assetHelper = new AssetsHelper($packagesMock);
$mauticSecurity = $this->createMock(CorePermissions::class);
$mauticSecurity->method('hasEntityAccess')
->willReturn(false);
$analyticsHelper = new AnalyticsHelper($coreParametersHelper);
$pageModel = $this->createMock(PageModel::class);
$pageModel->method('getHitQuery')
->willReturn([]);
$pageModel->method('getEntityBySlugs')
->willReturn($pageEntityA);
$pageModel->method('hitPage')
->willReturn(true);
$this->contactRequestHelper->method('getContactFromQuery')
->willReturn(new Lead());
$router = $this->createMock(Router::class);
$dispatcher = new EventDispatcher();
$modelFactory = $this->createMock(ModelFactory::class);
$modelFactory->method('getModel')
->willReturnMap(
[
['page', $pageModel],
['lead', $this->leadModel],
]
);
$container = $this->createMock(Container::class);
$container->expects(self::never())
->method('has');
$container->expects(self::never())
->method('get');
$this->request->attributes->set('ignore_mismatch', true);
$router = $this->createMock(RouterInterface::class);
$doctrine = $this->createMock(ManagerRegistry::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$translator = $this->createMock(Translator::class);
$flashBag = $this->createMock(FlashBag::class);
$themeHelper = $this->createMock(ThemeHelper::class);
$themeHelper->expects(self::never())
->method('checkForTwigTemplate');
$requestStack = new RequestStack();
$controller = new PublicController(
$doctrine,
$modelFactory,
$userHelper,
$coreParametersHelper,
$dispatcher,
$translator,
$flashBag,
$requestStack,
$mauticSecurity
);
$controller->setContainer($container);
$response = $controller->indexAction(
$this->request,
$this->contactRequestHelper,
$cookieHelper,
$analyticsHelper,
$assetHelper,
$themeHelper,
$this->createMock(Tracking404Model::class),
$router,
$this->createMock(DeviceTrackingServiceInterface::class),
'/page/a',
);
return $response->getContent();
}
public function testThatInvalidClickTroughGetsProcessed(): void
{
$redirectId = 'someRedirectId';
$clickTrough = 'someClickTroughValue';
$redirectUrl = 'https://someurl.test/';
$this->redirectModel->expects(self::once())
->method('getRedirectById')
->with($redirectId)
->willReturn($this->redirect);
$matcher = self::exactly(2);
$this->modelFactory->expects($matcher)
->method('getModel')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame(RedirectModel::class, $parameters[0]);
return $this->redirectModel;
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame(PageModel::class, $parameters[0]);
return $this->pageModel;
}
self::fail('The index '.$matcher->numberOfInvocations().' is not set.');
});
$this->redirect->expects(self::once())
->method('isPublished')
->with(false)
->willReturn(true);
$this->redirect->expects(self::once())
->method('getUrl')
->willReturn($redirectUrl);
$this->ipLookupHelper->expects(self::once())
->method('getIpAddress')
->willReturn($this->ipAddress);
$this->ipAddress->expects(self::once())
->method('isTrackable')
->willReturn(true);
$getContactFromRequestCallback = function ($queryFields) use ($clickTrough) {
if (empty($queryFields)) {
return null;
}
throw new InvalidDecodedStringException($clickTrough);
};
$this->contactRequestHelper->expects(self::exactly(2))
->method('getContactFromQuery')
->willReturnCallback($getContactFromRequestCallback);
$routerMock = $this->createMock(Router::class);
$routerMock->expects(self::once())
->method('generate')
->willReturn('/asset/');
$this->internalContainer
->expects(self::once())
->method('get')
->willReturnMap([
['router', Container::EXCEPTION_ON_INVALID_REFERENCE, $routerMock],
]);
$this->request->query->set('ct', $clickTrough);
$doctrine = $this->createMock(ManagerRegistry::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(Translator::class);
$flashBag = $this->createMock(FlashBag::class);
$requestStack = new RequestStack();
$mauticSecurity = $this->createMock(CorePermissions::class);
$controller = new PublicController(
$doctrine,
$this->modelFactory,
$userHelper,
$coreParametersHelper,
$dispatcher,
$translator,
$flashBag,
$requestStack,
$mauticSecurity
);
$controller->setContainer($this->internalContainer);
$response = $controller->redirectAction(
$this->request,
$this->contactRequestHelper,
$this->primaryCompanyHelper,
$this->ipLookupHelper,
$this->logger,
$redirectId
);
self::assertSame('https://someurl.test/', $response->getTargetUrl());
}
/**
* @throws \Exception
*/
#[DataProvider('provideRedirectUrls')]
public function testAssetRedirectUrlWithClickThrough(string $redirectUrl, string $targetUrl): void
{
$redirectId = 'dummy_redirect_id';
$clickThrough = 'dummy_click_through';
$this->redirectModel->expects(self::once())
->method('getRedirectById')
->with($redirectId)
->willReturn($this->redirect);
$matcher = self::exactly(2);
$this->modelFactory->expects($matcher)
->method('getModel')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame(RedirectModel::class, $parameters[0]);
return $this->redirectModel;
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame(PageModel::class, $parameters[0]);
return $this->pageModel;
}
self::fail('Unknown invocation.');
});
$this->redirect->expects(self::once())
->method('isPublished')
->with(false)
->willReturn(true);
$this->redirect->expects(self::once())
->method('getUrl')
->willReturn($redirectUrl);
$this->ipLookupHelper->expects(self::once())
->method('getIpAddress')
->willReturn($this->ipAddress);
$this->ipAddress->expects(self::once())
->method('isTrackable')
->willReturn(true);
$getContactFromRequestCallback = function ($queryFields) use ($clickThrough) {
if (empty($queryFields)) {
return null;
}
throw new InvalidDecodedStringException($clickThrough);
};
$this->contactRequestHelper->expects(self::exactly(2))
->method('getContactFromQuery')
->willReturnCallback($getContactFromRequestCallback);
$routerMock = $this->createMock(Router::class);
$routerMock->expects(self::once())
->method('generate')
->with('mautic_asset_download')
->willReturn('/asset');
$this->internalContainer
->expects(self::once())
->method('get')
->willReturnMap([
['router', Container::EXCEPTION_ON_INVALID_REFERENCE, $routerMock],
]);
$this->request->query->set('ct', $clickThrough);
$doctrine = $this->createMock(ManagerRegistry::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(Translator::class);
$flashBag = $this->createMock(FlashBag::class);
$requestStack = new RequestStack();
$mauticSecurity = $this->createMock(CorePermissions::class);
$controller = new PublicController(
$doctrine,
$this->modelFactory,
$userHelper,
$coreParametersHelper,
$dispatcher,
$translator,
$flashBag,
$requestStack,
$mauticSecurity
);
$controller->setContainer($this->internalContainer);
$response = $controller->redirectAction(
$this->request,
$this->contactRequestHelper,
$this->primaryCompanyHelper,
$this->ipLookupHelper,
$this->logger,
$redirectId
);
self::assertSame($targetUrl, $response->getTargetUrl());
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
}
public static function provideRedirectUrls(): \Generator
{
yield 'No query parameters' => [
'https://some.test.url/asset/1:examplefilejpg',
'https://some.test.url/asset/1:examplefilejpg?ct=dummy_click_through',
];
yield 'With query parameter' => [
'https://some.test.url/asset/1:examplefilejpg?param=value',
'https://some.test.url/asset/1:examplefilejpg?param=value&ct=dummy_click_through',
];
yield 'With click-through parameter' => [
'https://some.test.url/asset/1:examplefilejpg?ct=parameter',
'https://some.test.url/asset/1:examplefilejpg?ct=dummy_click_through',
];
}
/**
* @throws \Exception
*/
public function testMtcTrackingEvent(): void
{
$request = new Request(
[
'foo' => 'bar',
]
);
$contact = new Lead();
$contact->setEmail('foo@bar.com');
$mtcSessionEventArray = ['mtc' => 'foobar'];
$event = new TrackingEvent($contact, $request, $mtcSessionEventArray);
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$eventDispatcher->expects($this->once())
->method('dispatch')
->with($event, PageEvents::ON_CONTACT_TRACKED)
->willReturnCallback(
function (TrackingEvent $event) {
$contact = $event->getContact()->getEmail();
$request = $event->getRequest();
$response = $event->getResponse();
$response->set('tracking', $contact);
$response->set('foo', $request->get('foo'));
return $event;
}
);
$security = $this->createMock(CorePermissions::class);
$security->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$pageModel = $this->createMock(PageModel::class);
$modelFactory = $this->createMock(ModelFactory::class);
$modelFactory->expects($this->once())
->method('getModel')
->with('page')
->willReturn($pageModel);
$deviceTrackingService = $this->createMock(DeviceTrackingServiceInterface::class);
$trackingHelper = $this->createMock(TrackingHelper::class);
$trackingHelper->expects($this->once())
->method('getCacheItem')
->willReturn($mtcSessionEventArray);
$contactTracker = $this->createMock(ContactTracker::class);
$contactTracker->method('getContact')
->willReturn($contact);
$doctrine = $this->createMock(ManagerRegistry::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$translator = $this->createMock(Translator::class);
$flashBag = $this->createMock(FlashBag::class);
$requestStack = new RequestStack();
$publicController = new PublicController(
$doctrine,
$modelFactory,
$userHelper,
$coreParametersHelper,
$eventDispatcher,
$translator,
$flashBag,
$requestStack,
$security
);
$response = $publicController->trackingAction(
$request,
$deviceTrackingService,
$trackingHelper,
$contactTracker
);
$json = json_decode($response->getContent(), true);
$this->assertEquals(
[
'mtc' => 'foobar',
'tracking' => 'foo@bar.com',
'foo' => 'bar',
],
$json['events']
);
}
public function testTrackingActionWithInvalidCt(): void
{
$request = new Request();
$pageModel = $this->createMock(PageModel::class);
$pageModel->expects($this->once())->method('hitPage')->willReturnCallback(
function (): void {
throw new InvalidDecodedStringException();
}
);
$modelFactory = $this->createMock(ModelFactory::class);
$modelFactory->expects($this->once())
->method('getModel')
->with('page')
->willReturn($pageModel);
$security = $this->createMock(CorePermissions::class);
$security->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$doctrine = $this->createMock(ManagerRegistry::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(Translator::class);
$flashBag = $this->createMock(FlashBag::class);
$requestStack = new RequestStack();
$publicController = new PublicController(
$doctrine,
$modelFactory,
$userHelper,
$coreParametersHelper,
$dispatcher,
$translator,
$flashBag,
$requestStack,
$security
);
$response = $publicController->trackingAction(
$request,
$this->createMock(DeviceTrackingServiceInterface::class),
$this->createMock(TrackingHelper::class),
$this->createMock(ContactTracker::class)
);
$this->assertEquals(
['success' => 0],
json_decode($response->getContent(), true)
);
}
public function testTrackingImageAction(): void
{
$this->client->request('GET', '/mtracking.gif?url=http%3A%2F%2Fmautic.org');
$this->assertResponseStatusCodeSame(200);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class VisitPageWitIpAnonymizationOffFunctionalTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->configParams['anonymize_ip'] = false;
parent::setUp();
}
public function testPageWithIpAnonymizationOff(): void
{
// create landing page
$pageObject = new Page();
$pageObject->setIsPublished(true);
$pageObject->setDateAdded(new \DateTime());
$pageObject->setTitle('Page:Page:Anonymization:Off');
$pageObject->setAlias('page-page-anonymizaiton-off');
$pageObject->setTemplate('Blank');
$pageObject->setCustomHtml('Test Html');
$pageObject->setLanguage('en');
$this->em->persist($pageObject);
$this->em->flush();
$this->logoutUser();
$pageContent = $this->client->request(Request::METHOD_GET, '/page-page-anonymizaiton-off');
Assert::assertTrue($this->client->getResponse()->isOk(), $pageContent->text());
Assert::assertStringContainsString('Test Html', $pageContent->text());
/** @var HitRepository $hitRepository */
$hitRepository = $this->em->getRepository(Hit::class);
/** @var Hit[] $hits */
$hits = $hitRepository->findBy(['page' => $pageObject->getId()]);
Assert::assertCount(1, $hits);
Assert::assertSame('127.0.0.1', $hits[0]->getIpAddress()->getIpAddress());
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class VisitPageWitIpAnonymizationOnFunctionalTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->configParams['anonymize_ip'] = true;
parent::setUp();
}
public function testPageWithIpAnonymizationOn(): void
{
// create landing page
$pageObject = new Page();
$pageObject->setIsPublished(true);
$pageObject->setDateAdded(new \DateTime());
$pageObject->setTitle('Page:Page:Anonymization:On');
$pageObject->setAlias('page-page-anonymizaiton-on');
$pageObject->setTemplate('Blank');
$pageObject->setCustomHtml('Test Html');
$pageObject->setLanguage('en');
$this->em->persist($pageObject);
$this->em->flush();
$this->logoutUser();
$pageContent = $this->client->request(Request::METHOD_GET, '/page-page-anonymizaiton-on');
Assert::assertTrue($this->client->getResponse()->isOk(), $pageContent->text());
Assert::assertStringContainsString('Test Html', $pageContent->text());
/** @var HitRepository $hitRepository */
$hitRepository = $this->em->getRepository(Hit::class);
/** @var Hit[] $hits */
$hits = $hitRepository->findBy(['page' => $pageObject->getId()]);
Assert::assertCount(1, $hits);
Assert::assertSame('*.*.*.*', $hits[0]->getIpAddress()->getIpAddress());
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Entity;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use PHPUnit\Framework\Assert;
class HitRepositoryTest extends MauticMysqlTestCase
{
private HitRepository $hitRepository;
private IpAddress $ipAddress;
protected function setUp(): void
{
parent::setUp();
$this->hitRepository = $this->em->getRepository(Hit::class);
}
public function testGetLatestHitDateByLead(): void
{
Assert::assertNull($this->hitRepository->getLatestHitDateByLead(1, 'someId'));
Assert::assertNull($this->hitRepository->getLatestHitDateByLead(1));
$leadOne = $this->createLead();
$leadTwo = $this->createLead();
$this->createHit($leadOne, $dateOne = new \DateTime('-10 second'), 'one-first');
$this->createHit($leadOne, new \DateTime('-20 second'), 'one-first');
$this->createHit($leadOne, $dateThree = new \DateTime('-5 second'), 'one-second');
$this->createHit($leadTwo, new \DateTime('-50 second'), 'two-first');
$this->createHit($leadTwo, $dateFive = new \DateTime('-40 second'), 'two-first');
$this->em->flush();
$this->assertHitDate($dateOne, $leadOne, 'one-first');
$this->assertHitDate($dateThree, $leadOne, 'one-second');
$this->assertHitDate($dateFive, $leadTwo, 'two-first');
$this->assertHitDate($dateThree, $leadOne, null);
$this->assertHitDate($dateFive, $leadTwo, null);
Assert::assertNull($this->hitRepository->getLatestHitDateByLead((int) $leadOne->getId(), 'two-first'));
Assert::assertNull($this->hitRepository->getLatestHitDateByLead((int) $leadTwo->getId(), 'one-second'));
}
private function createHit(Lead $lead, \DateTime $dateHit, string $trackingId): void
{
$hit = new Hit();
$hit->setLead($lead);
$hit->setIpAddress($this->getIpAddress());
$hit->setDateHit($dateHit);
$hit->setTrackingId($trackingId);
$hit->setCode(200);
$this->em->persist($hit);
}
private function createLead(): Lead
{
$lead = new Lead();
$this->em->persist($lead);
return $lead;
}
private function getIpAddress(): IpAddress
{
if (!isset($this->ipAddress)) {
$this->ipAddress = new IpAddress('127.0.0.1');
}
return $this->ipAddress;
}
private function assertHitDate(\DateTime $expectedHitDate, Lead $lead, ?string $trackingId): void
{
$hitDate = $this->hitRepository->getLatestHitDateByLead((int) $lead->getId(), $trackingId);
Assert::assertInstanceOf(\DateTime::class, $hitDate);
Assert::assertSame($expectedHitDate->getTimestamp(), $hitDate->getTimestamp());
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Entity;
use Mautic\PageBundle\Entity\Hit;
use PHPUnit\Framework\Assert;
class HitTest extends \PHPUnit\Framework\TestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('setUrlTitle')]
public function testSetUrlTitle(string $urlTitle, int $expected): void
{
$hit = new Hit();
$hit->setUrlTitle($urlTitle);
Assert::assertEquals($expected, mb_strlen($hit->getUrlTitle()));
}
/**
* @return iterable<array<int,int|string>>
*/
public static function setUrlTitle(): iterable
{
yield ['custom', 6];
yield ['Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars Title longer than 191 chars ', 191];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Test\Doctrine\RepositoryConfiguratorTrait;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\PageRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class PageRepositoryTest extends TestCase
{
use RepositoryConfiguratorTrait;
private function getRepository(): PageRepository
{
$repository = $this->configureRepository(Page::class);
$this->connection->method('createQueryBuilder')->willReturnCallback(fn () => new QueryBuilder($this->connection));
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnCallback(fn ($id) => match ($id) {
'mautic.page.searchcommand.isexpired' => 'is:expired',
'mautic.page.searchcommand.ispending' => 'is:pending',
default => $id,
});
$repository->setTranslator($translator);
return $repository;
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataExpirationFilters')]
public function testAddSearchCommandWhereClauseHandlesExpirationFilters(string $command, string $expected): void
{
$repository = $this->getRepository();
$qb = $this->connection->createQueryBuilder();
$filter = (object) ['command' => $command, 'string' => '', 'not' => false, 'strict' => false];
$method = new \ReflectionMethod(PageRepository::class, 'addSearchCommandWhereClause');
$method->setAccessible(true);
[$expr, $params] = $method->invoke($repository, $qb, $filter);
self::assertSame($expected, (string) $expr);
self::assertSame(['par1' => true], $params);
}
/**
* @return iterable<array{0: string, 1: string}>
*/
public static function dataExpirationFilters(): iterable
{
yield ['is:expired', "(p.isPublished = :par1 AND p.publishDown IS NOT NULL AND p.publishDown <> '' AND p.publishDown < CURRENT_TIMESTAMP())"];
yield ['is:pending', "(p.isPublished = :par1 AND p.publishUp IS NOT NULL AND p.publishUp <> '' AND p.publishUp > CURRENT_TIMESTAMP())"];
}
public function testGetSearchCommandsContainsExpirationFilters(): void
{
$repository = $this->getRepository();
$commands = $repository->getSearchCommands();
self::assertContains('mautic.page.searchcommand.isexpired', $commands);
self::assertContains('mautic.page.searchcommand.ispending', $commands);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Entity;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
class PageTest extends \PHPUnit\Framework\TestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('setIsPreferenceCenterDataProvider')]
public function testSetIsPreferenceCenter($value, $expected, array $changes): void
{
$page = new Page();
$page->setIsPreferenceCenter($value);
Assert::assertSame($expected, $page->getIsPreferenceCenter());
Assert::assertSame($changes, $page->getChanges());
}
public static function setIsPreferenceCenterDataProvider(): iterable
{
yield [null, null, []];
yield [true, true, ['isPreferenceCenter' => [null, true]]];
yield [false, false, ['isPreferenceCenter' => [null, false]]];
yield ['', false, ['isPreferenceCenter' => [null, false]]];
yield [0, false, ['isPreferenceCenter' => [null, false]]];
yield ['string', true, ['isPreferenceCenter' => [null, true]]];
}
#[\PHPUnit\Framework\Attributes\DataProvider('setNoIndexDataProvider')]
public function testSetNoIndex($value, $expected, array $changes): void
{
$page = new Page();
$page->setNoIndex($value);
Assert::assertSame($expected, $page->getNoIndex());
Assert::assertSame($changes, $page->getChanges());
}
public static function setNoIndexDataProvider(): iterable
{
yield [null, null, []];
yield [true, true, ['noIndex' => [null, true]]];
yield [false, false, ['noIndex' => [null, false]]];
yield ['', false, ['noIndex' => [null, false]]];
yield [0, false, ['noIndex' => [null, false]]];
yield ['string', true, ['noIndex' => [null, true]]];
}
/**
* Test setHeadScript and getHeadScript.
*/
public function testSetHeadScript(): void
{
$script = '<script>console.log("test")';
$page = new Page();
$page->setHeadScript($script);
$this->assertEquals($script, $page->getHeadScript());
}
/**
* Test setFooterScript and getFooterScript.
*/
public function testSetFooterScript(): void
{
$script = '<script>console.log("test")';
$page = new Page();
$page->setFooterScript($script);
$this->assertEquals($script, $page->getFooterScript());
}
#[\PHPUnit\Framework\Attributes\DataProvider('setIsDuplicateDataProvider')]
public function testIsDuplicate(bool $isDuplicate): void
{
$page = new Page();
$page->setIsDuplicate($isDuplicate);
Assert::assertIsBool($page->isDuplicate());
}
/**
* @return iterable<array{bool}>
*/
public static function setIsDuplicateDataProvider(): iterable
{
yield [true];
yield [false];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Entity;
use Mautic\PageBundle\Entity\Redirect;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
class RedirectTest extends TestCase
{
public function testGetUrlRemovesWhitespace(): void
{
$redirect = new Redirect();
$reflected = new \ReflectionClass(Redirect::class);
$property = $reflected->getProperty('url');
$property->setAccessible(true);
$property->setValue($redirect, 'https://example.com '); // trailing whitespace
Assert::assertSame('https://example.com', $redirect->getUrl());
}
public function testSetUrlRemovesWhitespace(): void
{
$redirect = new Redirect();
$reflected = new \ReflectionClass(Redirect::class);
$property = $reflected->getProperty('url');
$property->setAccessible(true);
$redirect->setUrl('https://example.com '); // trailing whitespace
Assert::assertSame('https://example.com', $property->getValue($redirect));
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\EventListener;
use Doctrine\Common\Collections\Collection;
use Mautic\CoreBundle\Event\DetermineWinnerEvent;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\EventListener\DetermineWinnerSubscriber;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class DetermineWinnerSubscriberTest extends TestCase
{
/**
* @var MockObject|HitRepository
*/
private MockObject $hitRepository;
/**
* @var MockObject|TranslatorInterface
*/
private MockObject $translator;
private DetermineWinnerSubscriber $subscriber;
protected function setUp(): void
{
parent::setUp();
$this->hitRepository = $this->createMock(HitRepository::class);
$this->translator = $this->createMock(TranslatorInterface::class);
$this->subscriber = new DetermineWinnerSubscriber($this->hitRepository, $this->translator);
}
public function testOnDetermineBounceRateWinner(): void
{
$parentMock = $this->createMock(Page::class);
$childMock = $this->createMock(Page::class);
$children = [2 => $childMock];
$transChildren = $this->createMock(Collection::class);
$ids = [1, 3];
$parameters = ['parent' => $parentMock, 'children' => $children];
$event = new DetermineWinnerEvent($parameters);
$startDate = new \DateTime();
$translation = 'bounces';
$bounces = [
1 => [
'totalHits' => 20,
'bounces' => 5,
'rate' => 25,
'title' => 'Page 1.1',
],
2 => [
'totalHits' => 10,
'bounces' => 1,
'rate' => 10,
'title' => 'Page 1.2',
],
3 => [
'totalHits' => 30,
'bounces' => 15,
'rate' => 50,
'title' => 'Page 2.1',
],
4 => [
'totalHits' => 10,
'bounces' => 5,
'rate' => 50,
'title' => 'Page 2.2',
],
];
$this->translator
->method('trans')
->willReturn($translation);
$parentMock
->method('hasTranslations')
->willReturn(1);
$childMock
->method('hasTranslations')
->willReturn(1);
$transChildren->method('getKeys')
->willReturnOnConsecutiveCalls([2], [4]);
$parentMock
->method('getTranslationChildren')
->willReturn($transChildren);
$childMock
->method('getTranslationChildren')
->willReturn($transChildren);
$parentMock->expects(self::once())
->method('getRelatedEntityIds')
->willReturn($ids);
$parentMock
->method('getId')
->willReturn(1);
$childMock
->method('getId')
->willReturn(3);
$parentMock->expects(self::once())
->method('getVariantStartDate')
->willReturn($startDate);
$this->hitRepository->expects(self::once())
->method('getBounces')
->with($ids, $startDate)
->willReturn($bounces);
$this->subscriber->onDetermineBounceRateWinner($event);
$expectedData = [20, 50];
$abTestResults = $event->getAbTestResults();
// Check for lowest bounce rates
self::assertEquals([1], $abTestResults['winners']);
self::assertEquals($expectedData, $abTestResults['support']['data'][$translation]);
}
public function testOnDetermineDwellTimeWinner(): void
{
$parentMock = $this->createMock(Page::class);
$ids = [1, 2];
$parameters = ['parent' => $parentMock];
$event = new DetermineWinnerEvent($parameters);
$startDate = new \DateTime();
$translation = 'dewlltime';
$counts = [
1 => [
'sum' => 1000,
'min' => 5,
'max' => 200,
'average' => 50,
'count' => 10,
'title' => 'title',
],
2 => [
'sum' => 2000,
'min' => 10,
'max' => 300,
'average' => 70,
'count' => 50,
'title' => 'title',
],
];
$this->translator->expects($this->any())
->method('trans')
->willReturn($translation);
$parentMock->expects($this->once())
->method('getRelatedEntityIds')
->willReturn($ids);
$parentMock->expects($this->any())
->method('getId')
->willReturn(1);
$parentMock->expects($this->once())
->method('getVariantStartDate')
->willReturn($startDate);
$this->hitRepository->expects($this->once())
->method('getDwellTimesForPages')
->with($ids, ['fromDate' => $startDate])
->willReturn($counts);
$this->subscriber->onDetermineDwellTimeWinner($event);
$expectedData = [50, 70];
$abTestResults = $event->getAbTestResults();
$this->assertEquals($abTestResults['winners'], [2]);
$this->assertEquals($abTestResults['support']['data'][$translation], $expectedData);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Mautic\PageBundle\Tests\EventListener;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\LanguageHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event\PageBuilderEvent;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\EventListener\PageSubscriber;
use Mautic\PageBundle\Model\PageDraftModel;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\PageEvents;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Asset\Packages;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
class PageSubscriberTest extends TestCase
{
public function testGetTokensWhenCalledReturnsValidTokens(): void
{
$translator = $this->createMock(Translator::class);
$pageBuilderEvent = new PageBuilderEvent($translator);
$pageBuilderEvent->addToken('{token_test}', 'TOKEN VALUE');
$tokens = $pageBuilderEvent->getTokens();
$this->assertArrayHasKey('{token_test}', $tokens);
$this->assertEquals($tokens['{token_test}'], 'TOKEN VALUE');
}
public function testOnPageDisplayBodyTagRegex(): void
{
$dummyPageContent = <<<EOF
<html>
<head>
</head>
<body class="mt-6 md:max-w-2xl p-[5px]" onclick="myFunction()" data-help-text="téxt with nön äscii charactêrs">
</body>
</html>
EOF;
$event = new PageDisplayEvent(
$dummyPageContent,
$this->createMock(Page::class)
);
$dispatcher = new EventDispatcher();
$subscriber = $this->getPageSubscriber();
$dispatcher->addSubscriber($subscriber);
$dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
$this->assertEquals(
$event->getContent(),
<<<EOF
<html>
<head>
</head>
<body class="mt-6 md:max-w-2xl p-[5px]" onclick="myFunction()" data-help-text="téxt with nön äscii charactêrs">
<script data-source="mautic">
const foo='bar';
</script>
</body>
</html>
EOF
);
}
/**
* Get page subscriber with mocked dependencies.
*/
protected function getPageSubscriber(): PageSubscriber
{
/** @var Packages&MockObject $packagesMock */
$packagesMock = $this->createMock(Packages::class);
$assetsHelperMock = new AssetsHelper($packagesMock);
$ipLookupHelperMock = $this->createMock(IpLookupHelper::class);
$auditLogModelMock = $this->createMock(AuditLogModel::class);
$pageModel = $this->createMock(PageModel::class);
$languageHelper = $this->createMock(LanguageHelper::class);
$pageDraftModel = $this->createMock(PageDraftModel::class);
$assetsHelperMock->addScriptDeclaration("const foo='bar';", 'onPageDisplay_bodyOpen');
return new PageSubscriber(
$assetsHelperMock,
$ipLookupHelperMock,
$auditLogModelMock,
$languageHelper,
$pageModel,
$pageDraftModel,
);
}
/**
* Get non empty payload, having a Request and non-null entity IDs.
*
* @return array<string, bool|int|MockObject>
*/
protected function getNonEmptyPayload(): array
{
$requestMock = $this->createMock(Request::class);
return [
'request' => $requestMock,
'isNew' => true,
'hitId' => 123,
'pageId' => 456,
'leadId' => 789,
];
}
/**
* Get empty payload with all null entity IDs.
*
* @return array<string, null>
*/
protected function getEmptyPayload(): array
{
return array_fill_keys(['request', 'isNew', 'hitId', 'pageId', 'leadId'], null);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\PageBundle\Tests\EventListener;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event\PageHitEvent;
use Mautic\PageBundle\EventListener\PointSubscriber;
use Mautic\PageBundle\Helper\PointActionHelper;
use Mautic\PointBundle\Event\PointBuilderEvent;
use Mautic\PointBundle\Model\PointModel;
use PHPUnit\Framework\TestCase;
class PointSubscriberTest extends TestCase
{
public function testSubscribedEvents(): void
{
self::assertEquals(
[
'mautic.point_on_build' => ['onPointBuild', 0],
'mautic.page_on_hit' => ['onPageHit', 0],
],
PointSubscriber::getSubscribedEvents()
);
}
public function testPointBuildAddsActions(): void
{
$pointModel = $this->createMock(PointModel::class);
$pointBuilderEvent = $this->createMock(PointBuilderEvent::class);
$pointActionHelper = $this->createMock(PointActionHelper::class);
$matcher = self::exactly(2);
$pointBuilderEvent->expects($matcher)->method('addAction')->willReturnCallback(function (...$parameters) use ($matcher, $pointActionHelper) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame('page.hit', $parameters[0]);
$this->assertSame([
'group' => 'mautic.page.point.action',
'label' => 'mautic.page.point.action.pagehit',
'description' => 'mautic.page.point.action.pagehit_descr',
'callback' => [PointActionHelper::class, 'validatePageHit'],
'formType' => \Mautic\PageBundle\Form\Type\PointActionPageHitType::class,
], $parameters[1]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame('url.hit', $parameters[0]);
$this->assertSame([
'group' => 'mautic.page.point.action',
'label' => 'mautic.page.point.action.urlhit',
'description' => 'mautic.page.point.action.urlhit_descr',
'callback' => [$pointActionHelper, 'validateUrlHit'],
'formType' => \Mautic\PageBundle\Form\Type\PointActionUrlHitType::class,
'formTheme' => '@MauticPage/FormTheme/Point/pointaction_urlhit_widget.html.twig',
], $parameters[1]);
}
});
$pointSubscriber = new PointSubscriber($pointModel, $pointActionHelper);
$pointSubscriber->onPointBuild($pointBuilderEvent);
}
public function testPageHitTriggersPageHitWhenPageIsSet(): void
{
$pageHitEvent = $this->createMock(PageHitEvent::class);
$page = $this->createMock(Page::class);
$hit = $this->createMock(Hit::class);
$lead = $this->createMock(Lead::class);
$pointModel = $this->createMock(PointModel::class);
$pointActionHelper = $this->createMock(PointActionHelper::class);
$pageHitEvent->expects(self::once())->method('getPage')->willReturn($page);
$pageHitEvent->expects(self::once())->method('getHit')->willReturn($hit);
$pageHitEvent->expects(self::once())->method('getLead')->willReturn($lead);
$pointModel->expects(self::once())->method('triggerAction')->with('page.hit', $hit, null, $lead);
$pointSubscriber = new PointSubscriber($pointModel, $pointActionHelper);
$pointSubscriber->onPageHit($pageHitEvent);
}
public function testURLHitTriggersPageHitWhenPageIsSet(): void
{
$pageHitEvent = $this->createMock(PageHitEvent::class);
$hit = $this->createMock(Hit::class);
$lead = $this->createMock(Lead::class);
$pointModel = $this->createMock(PointModel::class);
$pointActionHelper = $this->createMock(PointActionHelper::class);
$pageHitEvent->expects(self::once())->method('getPage')->willReturn(null);
$pageHitEvent->expects(self::once())->method('getHit')->willReturn($hit);
$pageHitEvent->expects(self::once())->method('getLead')->willReturn($lead);
$pointModel->expects(self::once())->method('triggerAction')->with('url.hit', $hit, null, $lead);
$pointSubscriber = new PointSubscriber($pointModel, $pointActionHelper);
$pointSubscriber->onPageHit($pageHitEvent);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\EventListener;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\ReportBundle\Tests\Functional\AbstractReportSubscriberTestCase;
class ReportSubscriberFunctionalTest extends AbstractReportSubscriberTestCase
{
public function testPageHitReportWithDncListColumn(): void
{
$leads[] = $this->createContact('test1@example.com');
$leads[] = $this->createContact('test2@example.com');
$leads[] = $this->createContact('test3@example.com');
$this->em->flush();
$this->createPageHit($leads[0], 2);
$this->createPageHit($leads[1]);
$this->createPageHit($leads[2], 1, 'https://mautic.org');
$this->createDnc('email', $leads[0], DoNotContact::BOUNCED);
$this->createDnc('email', $leads[1], DoNotContact::MANUAL);
$this->createDnc('email', $leads[2], DoNotContact::UNSUBSCRIBED);
$this->createDnc('sms', $leads[2], DoNotContact::MANUAL);
$this->em->flush();
$report = $this->createReport(
source: 'page.hits',
columns: ['l.id', 'ph.url', 'dnc_preferences'],
filters: [
[
'column' => 'dnc_preferences',
'glue' => 'and',
'dynamic' => null,
'condition' => 'in',
'value' => [
'email:'.DoNotContact::UNSUBSCRIBED,
'email:'.DoNotContact::BOUNCED,
],
],
],
order: [['column' => 'l.id', 'direction' => 'ASC']]
);
$expectedReport = [
// id, url, dnc_preferences
[(string) $leads[0]->getId(), 'https://example.com', 'DNC Bounced: Email'],
[(string) $leads[0]->getId(), 'https://example.com', 'DNC Bounced: Email'],
[(string) $leads[2]->getId(), 'https://mautic.org', 'DNC Manually Unsubscribed: Text Message, DNC Unsubscribed: Email'],
];
$this->verifyReport($report->getId(), $expectedReport);
$this->verifyApiReport($report->getId(), $expectedReport);
}
private function createPageHit(Lead $lead, int $times = 1, string $url = 'https://example.com'): void
{
for ($i = 0; $i < $times; ++$i) {
$pageHit = new Hit();
$pageHit->setLead($lead);
$pageHit->setDateHit(new \DateTime());
$pageHit->setCode(200);
$pageHit->setUrl($url);
$pageHit->setTrackingId(substr(bin2hex(random_bytes(8)), 0, 16));
$this->em->persist($pageHit);
}
}
private function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
public function createDnc(string $channel, Lead $contact, int $reason): DoNotContact
{
$dnc = new DoNotContact();
$dnc->setChannel($channel);
$dnc->setLead($contact);
$dnc->setReason($reason);
$dnc->setDateAdded(new \DateTime());
$this->em->persist($dnc);
return $dnc;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\EventListener;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\ReportBundle\Tests\Functional\AbstractReportSubscriberTestCase;
class ReportSubscriberFunctionalTestCase extends AbstractReportSubscriberTestCase
{
public function testPageHitReportWithTimeSpent(): void
{
$leads[] = $this->createContact('test1@example.com');
$leads[] = $this->createContact('test2@example.com');
$leads[] = $this->createContact('test3@example.com');
$this->em->flush();
// generate page hits for contacts
$now = new \DateTime();
$this->createPageHit($leads[0], 1, 'https://example.com/page1', (clone $now)->modify('-1 hour'), (clone $now)->modify('-55 minutes'));
$this->createPageHit($leads[0], 1, 'https://example.com/page2', (clone $now)->modify('-50 minutes'), (clone $now)->modify('-49 minutes 59 seconds'));
$this->createPageHit($leads[1], 1, 'https://example.com/page1', (clone $now)->modify('-40 minutes'));
$this->createPageHit($leads[2], 1, 'https://example.com/page3', (clone $now)->modify('-30 minutes'), (clone $now)->modify('-25 minutes'));
$this->em->flush();
$report = $this->createReport(
source: 'page.hits',
columns: ['l.id', 'ph.url', 'ph.time_spent'],
order: [['column' => 'l.id', 'direction' => 'ASC']]
);
$expectedReport = [
// id, url, time_spent
[(string) $leads[0]->getId(), 'https://example.com/page1', '00:05:00'],
[(string) $leads[0]->getId(), 'https://example.com/page2', '00:01:59'],
[(string) $leads[1]->getId(), 'https://example.com/page1', ''],
[(string) $leads[2]->getId(), 'https://example.com/page3', '00:05:00'],
];
$this->verifyReport($report->getId(), $expectedReport);
$this->verifyApiReport($report->getId(), $expectedReport);
}
private function createPageHit(Lead $lead, int $times = 1, string $url = 'https://example.com', ?\DateTime $dateHit = null, ?\DateTime $dateLeft = null): void
{
for ($i = 0; $i < $times; ++$i) {
$pageHit = new Hit();
$pageHit->setLead($lead);
$pageHit->setDateHit($dateHit);
$pageHit->setDateLeft($dateLeft);
$pageHit->setCode(200);
$pageHit->setUrl($url);
$pageHit->setTrackingId(substr(bin2hex(random_bytes(8)), 0, 16));
$this->em->persist($pageHit);
}
}
private function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
}

View File

@@ -0,0 +1,353 @@
<?php
namespace Mautic\PageBundle\Tests\EventListener;
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Result;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\LeadBundle\Model\CompanyReportData;
use Mautic\LeadBundle\Report\DncReportService;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\EventListener\ReportSubscriber;
use Mautic\ReportBundle\Entity\Report;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\Event\ReportGraphEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class ReportSubscriberTest extends TestCase
{
/**
* @var CompanyReportData|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $companyReportData;
/**
* @var HitRepository|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $hitRepository;
/**
* @var TranslatorInterface|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $translator;
private \PHPUnit\Framework\MockObject\MockObject&DncReportService $dncReportService;
private ReportSubscriber $subscriber;
public function setUp(): void
{
parent::setUp();
$this->companyReportData = $this->createMock(CompanyReportData::class);
$this->hitRepository = $this->createMock(HitRepository::class);
$this->translator = $this->createMock(TranslatorInterface::class);
$this->dncReportService = $this->createMock(DncReportService::class);
$this->subscriber = new ReportSubscriber(
$this->companyReportData,
$this->hitRepository,
$this->translator,
$this->dncReportService
);
}
public function testOnReportBuilderAddsPageAndPageHitReports(): void
{
$mockEvent = $this->createMock(ReportBuilderEvent::class);
$mockEvent->expects($this->once())
->method('getStandardColumns')
->willReturn([]);
$mockEvent->expects($this->once())
->method('getCategoryColumns')
->willReturn([]);
$mockEvent->expects($this->once())
->method('getCampaignByChannelColumns')
->willReturn([]);
$mockEvent->expects($this->exactly(3))
->method('checkContext')
->willReturn(true);
$setTables = [];
$setGraphs = [];
$mockEvent->expects($this->exactly(3))
->method('addTable')
->willReturnCallback(function () use (&$setTables): void {
$args = func_get_args();
$setTables[] = $args;
});
$mockEvent->expects($this->exactly(9))
->method('addGraph')
->willReturnCallback(function () use (&$setGraphs): void {
$args = func_get_args();
$setGraphs[] = $args;
});
$this->companyReportData->expects($this->once())
->method('getCompanyData')
->with()
->willReturn([]);
$this->subscriber->onReportBuilder($mockEvent);
$this->assertCount(3, $setTables);
$this->assertCount(9, $setGraphs);
}
public function testOnReportGeneratePagesContext(): void
{
$mockEvent = $this->getMockBuilder(ReportGeneratorEvent::class)
->disableOriginalConstructor()
->onlyMethods([
'getContext',
'getQueryBuilder',
'addCategoryLeftJoin',
'setQueryBuilder',
'getReport',
])
->getMock();
$reportMock = $this->createMock(Report::class);
$reportMock->expects($this->once())
->method('getGroupBy')
->willReturn('');
$mockQueryBuilder = $this->getMockBuilder(QueryBuilder::class)
->disableOriginalConstructor()
->onlyMethods(['from', 'leftJoin'])
->getMock();
$mockQueryBuilder->expects($this->once())
->method('from')
->willReturn($mockQueryBuilder);
$mockQueryBuilder->expects($this->exactly(2))
->method('leftJoin')
->willReturn($mockQueryBuilder);
$mockEvent->expects($this->once())
->method('getQueryBuilder')
->willReturn($mockQueryBuilder);
$mockEvent->expects($this->once())
->method('getContext')
->willReturn('pages');
$mockEvent->expects($this->once())
->method('getReport')
->willReturn($reportMock);
$this->subscriber->onReportGenerate($mockEvent);
}
public function testOnReportGeneratePageHitsContext(): void
{
$mockEvent = $this->getMockBuilder(ReportGeneratorEvent::class)
->disableOriginalConstructor()
->onlyMethods([
'getContext',
'getQueryBuilder',
'addCategoryLeftJoin',
'addIpAddressLeftJoin',
'addLeadLeftJoin',
'addCampaignByChannelJoin',
'applyDateFilters',
'setQueryBuilder',
'getReport',
])
->getMock();
$reportMock = $this->createMock(Report::class);
$reportMock->expects($this->once())
->method('getGroupBy')
->willReturn('');
$mockQueryBuilder = $this->getMockBuilder(QueryBuilder::class)
->disableOriginalConstructor()
->onlyMethods(['from', 'leftJoin'])
->getMock();
$mockQueryBuilder->expects($this->once())
->method('from')
->willReturn($mockQueryBuilder);
$mockQueryBuilder->expects($this->exactly(5))
->method('leftJoin')
->willReturn($mockQueryBuilder);
$mockEvent->expects($this->once())
->method('getQueryBuilder')
->willReturn($mockQueryBuilder);
$mockEvent->expects($this->once())
->method('getContext')
->willReturn('page.hits');
$mockEvent->expects($this->once())
->method('getReport')
->willReturn($reportMock);
$this->subscriber->onReportGenerate($mockEvent);
}
public function testOnReportGraphGenerateBadContextWillReturn(): void
{
$mockEvent = $this->getMockBuilder(ReportGraphEvent::class)
->disableOriginalConstructor()
->onlyMethods(['checkContext', 'getRequestedGraphs'])
->getMock();
$mockEvent->expects($this->once())
->method('checkContext')
->willReturn(false);
$mockEvent->expects($this->never())
->method('getRequestedGraphs');
$this->subscriber->onReportGraphGenerate($mockEvent);
}
public function testOnReportGraphGenerate(): void
{
$mockEvent = $this->getMockBuilder(ReportGraphEvent::class)
->disableOriginalConstructor()
->onlyMethods([
'checkContext',
'getQuerybuilder',
'getOptions',
'getRequestedGraphs',
])
->getMock();
$this->translator->expects($this->any())
->method('trans')
->willReturnArgument(0);
$mockExprBuilder = $this->createMock(ExpressionBuilder::class);
$mockQueryBuilder = $this->getMockBuilder(QueryBuilder::class)
->disableOriginalConstructor()
->onlyMethods(['expr', 'executeQuery'])
->getMock();
$mockStmt = $this->getMockBuilder(Result::class)
->disableOriginalConstructor()
->onlyMethods(['fetchAllAssociative'])
->getMock();
$mockStmt->expects($this->exactly(2))
->method('fetchAllAssociative')
->willReturn(
[
[
'device' => 'iPhone',
'page_language' => 'en_US',
'the_count' => 3,
],
[
'device' => 'iPad',
'page_language' => 'en_GB',
'the_count' => 4,
],
]
);
$mockQueryBuilder->expects($this->any())
->method('expr')
->willReturn($mockExprBuilder);
$mockQueryBuilder->expects($this->any())
->method('executeQuery')
->willReturn($mockStmt);
$mockEvent->expects($this->once())
->method('getQueryBuilder')
->willReturn($mockQueryBuilder);
$mockChartQuery = $this->getMockBuilder(ChartQuery::class)
->disableOriginalConstructor()
->onlyMethods([
'modifyCountQuery',
'modifyTimeDataQuery',
'loadAndBuildTimeData',
'fetchCount',
'fetchCountDateDiff',
])
->getMock();
$mockChartQuery->expects($this->any())
->method('loadAndBuildTimeData')
->willReturn(['a', 'b', 'c']);
$mockChartQuery->expects($this->any())
->method('fetchCount')
->willReturn(2);
$mockChartQuery->expects($this->any())
->method('fetchCountDateDiff')
->willReturn(2);
$graphOptions = [
'chartQuery' => $mockChartQuery,
'translator' => $this->translator,
'dateFrom' => new \DateTime(),
'dateTo' => new \DateTime(),
];
$mockEvent->expects($this->once())
->method('checkContext')
->willReturn(true);
$mockEvent->expects($this->any())
->method('getOptions')
->willReturn($graphOptions);
$mockEvent->expects($this->once())
->method('getRequestedGraphs')
->willReturn(
[
'mautic.page.graph.line.hits',
'mautic.page.graph.line.time.on.site',
'mautic.page.graph.pie.time.on.site',
'mautic.page.graph.pie.new.vs.returning',
'mautic.page.graph.pie.languages',
'mautic.page.graph.pie.devices',
'mautic.page.table.referrers',
'mautic.page.table.most.visited',
'mautic.page.table.most.visited.unique',
]
);
$this->hitRepository->expects($this->exactly(2))
->method('getMostVisited')
->willReturn(['a', 'b', 'c']);
$this->hitRepository->expects($this->once())
->method('getReferers')
->willReturn(['a', 'b', 'c']);
$this->hitRepository->expects($this->once())
->method('getDwellTimeLabels')
->willReturn(
[
[
'from' => new \DateTime(),
'till' => new \DateTime(),
'label' => 'My Chart',
],
]
);
$this->subscriber->onReportGraphGenerate($mockEvent);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\PageBundle\Tests\Form\Type;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PageBundle\Entity\PageRepository;
use Mautic\PageBundle\Form\Type\PageListType;
use Mautic\PageBundle\Model\PageModel;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PageListTypeTest extends TestCase
{
private PageListType $page;
private \PHPUnit\Framework\MockObject\MockObject $pageModelMock;
public function setUp(): void
{
$corePermissionsHelper = $this->createMock(CorePermissions::class);
$this->pageModelMock = $this->createMock(PageModel::class);
$this->page = new PageListType($this->pageModelMock, $corePermissionsHelper);
}
public function testPageListTypeOptionsChoices(): void
{
$pageRepository = $this->createMock(PageRepository::class);
$resolver = new OptionsResolver();
$this->pageModelMock
->method('getRepository')
->willReturn($pageRepository);
$pageRepository->method('getPageList')
->willReturn([]);
$this->page->configureOptions($resolver);
$expectedOptions = [
'placeholder' => false,
'expanded' => false,
'multiple' => true,
'required' => false,
'top_level' => 'variant',
'ignore_ids' => [],
'choices' => [],
];
$this->assertEquals($expectedOptions, $resolver->resolve());
}
public function testGetParent(): void
{
$this->assertSame(ChoiceType::class, $this->page->getParent());
}
public function testGetBlockPrefix(): void
{
$this->assertSame('page_list', $this->page->getBlockPrefix());
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Mautic\PageBundle\Tests\Form\Type;
use Mautic\PageBundle\Form\Type\RedirectListType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RedirectListTypeTest extends TestCase
{
private RedirectListType $form;
protected function setUp(): void
{
$this->form = new RedirectListType();
}
public function testGetParent(): void
{
$this->assertSame(ChoiceType::class, $this->form->getParent());
}
public function testConfigureOptionsChoicesDefined(): void
{
$choices = [
'mautic.page.form.redirecttype.permanent' => 301,
'mautic.page.form.redirecttype.temporary' => 302,
'mautic.page.form.redirecttype.303_temporary' => 303,
'mautic.page.form.redirecttype.307_temporary' => 307,
'mautic.page.form.redirecttype.308_permanent' => 308,
];
$resolver = new OptionsResolver();
$this->form->configureOptions($resolver);
$expectedOptions = [
'choices' => $choices,
'expanded' => false,
'multiple' => false,
'label' => 'mautic.page.form.redirecttype',
'label_attr' => [
'class' => 'control-label',
],
'placeholder' => false,
'required' => false,
'attr' => [
'class' => 'form-control',
],
'feature' => 'all',
];
$this->assertSame($expectedOptions, $resolver->resolve());
}
public function testGetBlockPrefix(): void
{
$this->assertSame('redirect_list', $this->form->getBlockPrefix());
}
}

View File

@@ -0,0 +1,435 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Functional\EventListener;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Helper\MailHashHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList as Segment;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\Assert;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
class BuilderSubscriberTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
// Custom preference center page
public const CUSTOM_SEGMENT_SELECTOR = '.pref-segmentlist input';
public const CUSTOM_CATEGORY_SELECTOR = '.pref-categorylist input';
public const CUSTOM_PREFERRED_CHANNEL_SELECTOR = '.pref-preferredchannel select';
public const CUSTOM_CHANNEL_FREQ_SELECTOR = '.pref-channelfrequency div[data-contact-frequency="1"]';
public const CUSTOM_SAVE_BUTTON_SELECTOR = '.prefs-saveprefs a.btn-save';
// Default preference center page
public const DEFAULT_SEGMENT_SELECTOR = '#contact-segments';
public const DEFAULT_CATEGORY_SELECTOR = '#global-categories';
public const DEFAULT_PREFERRED_CHANNEL_SELECTOR = '#preferred_channel';
public const DEFAULT_CHANNEL_FREQ_SELECTOR = '[data-contact-frequency="1"]';
public const DEFAULT_PAUSE_DATES_SELECTOR = '[data-contact-pause-dates="1"]';
public const DEFAULT_SAVE_BUTTON_SELECTOR = '#lead_contact_frequency_rules_buttons_save';
// Common to both custom and default
public const TOKEN_SELECTOR = '#lead_contact_frequency_rules__token';
public const FORM_SELECTOR = 'form[name="lead_contact_frequency_rules"]';
protected function setUp(): void
{
$this->configParams['show_contact_preferences'] = 1;
$data = $this->providedData();
$this->configParams = array_merge($data[0], $this->configParams);
parent::setUp();
}
/**
* Tests both the default and custom preference center pages.
*
* @param mixed[] $configParams
* @param array<string,int> $selectorsAndExpectedCounts
*/
#[\PHPUnit\Framework\Attributes\DataProvider('frequencyFormRenderingDataProvider')]
public function testUnsubscribeFormRendersPreferenceCenterPageCorrectly(array $configParams, array $selectorsAndExpectedCounts, bool $hasPreferenceCenter): void
{
$emailStat = $this->createStat(
$this->createEmail($hasPreferenceCenter),
$lead = $this->createLead()
);
$this->createSegment();
$this->createCategory();
$this->em->flush();
$mailHashHelper = static::getContainer()->get(MailHashHelper::class);
\assert($mailHashHelper instanceof MailHashHelper);
$unsubscribeUrl = $this->router->generate('mautic_email_unsubscribe', [
'idHash' => $emailStat->getTrackingHash(),
'urlEmail' => $lead->getEmail(),
'secretHash' => $mailHashHelper->getEmailHash($lead->getEmail()),
], UrlGeneratorInterface::ABSOLUTE_PATH);
$crawler = $this->client->request('GET', $unsubscribeUrl);
self::assertTrue($this->client->getResponse()->isSuccessful(), $this->client->getResponse()->getContent());
$form = $crawler->filter(static::FORM_SELECTOR);
$html = $form->html();
foreach ($selectorsAndExpectedCounts as $selector => $expectedCount) {
$message = sprintf(
'The form HTML %s not contain the %s section. %s',
0 === $expectedCount ? 'should' : 'does',
$selector,
$html
);
Assert::assertCount(
$expectedCount,
$form->filter($selector),
$message
);
}
// Ensure the token and save button are always included within the <form> tag
Assert::assertCount(1, $form->filter(static::TOKEN_SELECTOR), sprintf('The following HTML does not contain the _token. %s', $html));
if ($hasPreferenceCenter) {
Assert::assertCount(1, $form->filter(static::CUSTOM_SAVE_BUTTON_SELECTOR), sprintf('The following HTML does not contain the save button. %s', $html));
} else {
Assert::assertCount(1, $form->filter(static::DEFAULT_SAVE_BUTTON_SELECTOR), sprintf('The following HTML does not contain the save button. %s', $html));
}
}
public static function frequencyFormRenderingDataProvider(): \Generator
{
yield 'Custom Preference Center: All preferences enabled' => [
[
'show_contact_segments' => 1,
'show_contact_categories' => 1,
'show_contact_preferred_channels' => 1,
'show_contact_frequency' => 1,
'show_contact_pause_dates' => 1,
],
[
static::CUSTOM_SEGMENT_SELECTOR => 1, // determined by show_contact_segments
static::CUSTOM_CATEGORY_SELECTOR => 1, // determined by show_contact_categories
static::CUSTOM_PREFERRED_CHANNEL_SELECTOR => 1, // determined by show_contact_preferred_channels
static::CUSTOM_CHANNEL_FREQ_SELECTOR => 1, // determined by EITHER show_contact_frequency & show_contact_pause_dates
],
true,
];
yield 'Custom Preference Center: Segments & Categories disabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 1,
'show_contact_frequency' => 1,
'show_contact_pause_dates' => 1,
],
[
static::CUSTOM_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::CUSTOM_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::CUSTOM_PREFERRED_CHANNEL_SELECTOR => 1, // determined by show_contact_preferred_channels
static::CUSTOM_CHANNEL_FREQ_SELECTOR => 1, // determined by EITHER show_contact_frequency & show_contact_pause_dates
],
true,
];
yield 'Custom Preference Center: Preferred Channels & Frequency disabled' => [
[
'show_contact_segments' => 1,
'show_contact_categories' => 1,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 0,
'show_contact_pause_dates' => 0,
],
[
static::CUSTOM_SEGMENT_SELECTOR => 1, // determined by show_contact_segments
static::CUSTOM_CATEGORY_SELECTOR => 1, // determined by show_contact_categories
static::CUSTOM_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::CUSTOM_CHANNEL_FREQ_SELECTOR => 0, // determined by EITHER show_contact_frequency & show_contact_pause_dates
],
true,
];
yield 'Custom Preference Center: Frequency enabled & Pause Dates disabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 1,
'show_contact_pause_dates' => 0,
],
[
static::CUSTOM_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::CUSTOM_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::CUSTOM_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::CUSTOM_CHANNEL_FREQ_SELECTOR => 1, // determined by EITHER show_contact_frequency & show_contact_pause_dates
],
true,
];
yield 'Custom Preference Center: Frequency disabled & Pause Dates enabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 0,
'show_contact_pause_dates' => 1,
],
[
static::CUSTOM_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::CUSTOM_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::CUSTOM_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::CUSTOM_CHANNEL_FREQ_SELECTOR => 0, // determined by show_contact_frequency
static::DEFAULT_PAUSE_DATES_SELECTOR => 1, // determined by show_contact_pause_dates
],
true,
];
yield 'Custom Preference Center: All preferences disabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 0,
'show_contact_pause_dates' => 0,
],
[
static::CUSTOM_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::CUSTOM_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::CUSTOM_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::CUSTOM_CHANNEL_FREQ_SELECTOR => 0, // determined by EITHER show_contact_frequency & show_contact_pause_dates
],
true,
];
yield 'Default Preference Center: All preferences enabled' => [
[
'show_contact_segments' => 1,
'show_contact_categories' => 1,
'show_contact_preferred_channels' => 1,
'show_contact_frequency' => 1,
'show_contact_pause_dates' => 1,
],
[
static::DEFAULT_SEGMENT_SELECTOR => 1, // determined by show_contact_segments
static::DEFAULT_CATEGORY_SELECTOR => 1, // determined by show_contact_categories
static::DEFAULT_PREFERRED_CHANNEL_SELECTOR => 1, // determined by show_contact_preferred_channels
static::DEFAULT_CHANNEL_FREQ_SELECTOR => 1, // determined by show_contact_frequency. This differs from a custom page.
static::DEFAULT_PAUSE_DATES_SELECTOR => 1, // determined FIRST by show_contact_frequency, then by show_contact_pause_dates
],
false,
];
yield 'Default Preference Center: Segments & Categories disabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 1,
'show_contact_frequency' => 1,
'show_contact_pause_dates' => 1,
],
[
static::DEFAULT_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::DEFAULT_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::DEFAULT_PREFERRED_CHANNEL_SELECTOR => 1, // determined by show_contact_preferred_channels
static::DEFAULT_CHANNEL_FREQ_SELECTOR => 1, // determined by show_contact_frequency. This differs from a custom page.
static::DEFAULT_PAUSE_DATES_SELECTOR => 1, // determined FIRST by show_contact_frequency, then by show_contact_pause_dates
],
false,
];
yield 'Default Preference Center: Preferred Channels & Frequency disabled' => [
[
'show_contact_segments' => 1,
'show_contact_categories' => 1,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 0,
'show_contact_pause_dates' => 0,
],
[
static::DEFAULT_SEGMENT_SELECTOR => 1, // determined by show_contact_segments
static::DEFAULT_CATEGORY_SELECTOR => 1, // determined by show_contact_categories
static::DEFAULT_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::DEFAULT_CHANNEL_FREQ_SELECTOR => 0, // determined by show_contact_frequency. This differs from a custom page.
static::DEFAULT_PAUSE_DATES_SELECTOR => 0, // determined FIRST by show_contact_frequency, then by show_contact_pause_dates
],
false,
];
yield 'Default Preference Center: Frequency enabled & Pause Dates disabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 1,
'show_contact_pause_dates' => 0,
],
[
static::DEFAULT_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::DEFAULT_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::DEFAULT_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::DEFAULT_CHANNEL_FREQ_SELECTOR => 1, // determined by show_contact_frequency. This differs from a custom page.
static::DEFAULT_PAUSE_DATES_SELECTOR => 0, // determined FIRST by show_contact_frequency, then by show_contact_pause_dates
],
false,
];
yield 'Default Preference Center: Frequency disabled & Pause Dates enabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 0,
'show_contact_pause_dates' => 1,
],
[
static::DEFAULT_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::DEFAULT_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::DEFAULT_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::DEFAULT_CHANNEL_FREQ_SELECTOR => 0, // determined by show_contact_frequency. This differs from a custom page.
static::DEFAULT_PAUSE_DATES_SELECTOR => 0, // determined FIRST by show_contact_frequency, then by show_contact_pause_dates
],
false,
];
yield 'Default Preference Center: All preferences disabled' => [
[
'show_contact_segments' => 0,
'show_contact_categories' => 0,
'show_contact_preferred_channels' => 0,
'show_contact_frequency' => 0,
'show_contact_pause_dates' => 0,
],
[
static::DEFAULT_SEGMENT_SELECTOR => 0, // determined by show_contact_segments
static::DEFAULT_CATEGORY_SELECTOR => 0, // determined by show_contact_categories
static::DEFAULT_PREFERRED_CHANNEL_SELECTOR => 0, // determined by show_contact_preferred_channels
static::DEFAULT_CHANNEL_FREQ_SELECTOR => 0, // determined by show_contact_frequency. This differs from a custom page.
static::DEFAULT_PAUSE_DATES_SELECTOR => 0, // determined FIRST by show_contact_frequency, then by show_contact_pause_dates
],
false,
];
}
private function createStat(Email $email, Lead $lead): Stat
{
$stat = new Stat();
$stat->setEmail($email);
$stat->setLead($lead);
$stat->setEmailAddress($lead->getEmail());
$stat->setDateSent(new \DateTime());
$stat->setTrackingHash(uniqid());
$this->em->persist($stat);
return $stat;
}
private function createEmail(bool $hasPreferenceCenter = true): Email
{
$email = new Email();
$email->setName('Example');
if ($hasPreferenceCenter) {
$email->setPreferenceCenter($this->createPage());
}
$this->em->persist($email);
return $email;
}
private function createLead(): Lead
{
$lead = new Lead();
$lead->setEmail('test@example.com');
$this->em->persist($lead);
return $lead;
}
private function createSegment(): Segment
{
$segment = new Segment();
$segment->setName('My Segment');
$segment->setPublicName('My Segment');
$segment->setAlias('my-segment');
$segment->setIsPreferenceCenter(true);
$this->em->persist($segment);
return $segment;
}
private function createCategory(): Category
{
$category = new Category();
$category->setTitle('My Category');
$category->setAlias('my-category');
$category->setIsPublished(true);
$category->setBundle('global');
$this->em->persist($category);
return $category;
}
private function createPage(): Page
{
$page = new Page();
$page->setTitle('Preference Center');
$page->setAlias('preference-center');
$page->setIsPreferenceCenter(true);
$page->setCustomHtml($this->getPageContent());
$page->setIsPublished(true);
$this->em->persist($page);
return $page;
}
private function getPageContent(): string
{
return <<<PAGE
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>{pagetitle}</title>
<meta name="description" content="{pagemetadescription}">
</head>
<body>
<div>
{langbar}
{sharebuttons}
</div>
<div>
{successmessage}
<div>
{segmentlist}
</div>
<div>
{categorylist}
</div>
<div>
{preferredchannel}
</div>
<div>
{channelfrequency}
</div>
<div>
{saveprefsbutton}
</div>
</div>
</body>
</html>
PAGE;
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Functional\Model;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\Redirect;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class PageModelTest extends MauticMysqlTestCase
{
private HitRepository $pageHitRepository;
private const DO_NOT_TRACK_IP = '218.30.65.10';
private const BOT_BLOCKED_IP = '218.30.65.11';
private const IP_NOT_IN_ANY_BLOCK_LIST = '218.30.65.12';
private const IP_NOT_IN_ANY_BLOCK_LIST2 = '218.30.65.111';
private const BOT_BLOCKED_USER_AGENTS = [
'AHC/2.1',
'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.6) Gecko/2009011913 Firefox/3.0.6',
'Mozilla/5.0 (compatible; Codewisebot/2.0; +http://www.nosite.com/somebot.htm)',
'Mozilla/5.0 (iPhone; CPU iPhone OS 8_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12B411 Safari/600.1.4 (compatible; YandexMobileBot/3.0; +http://yandex.com/bots)',
'serpstatbot/2.0 beta (advanced backlink tracking bot; http://serpstatbot.com/; abuse@serpstatbot.com)',
'Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; AspiegelBot)',
'serpstatbot/2.1 (advanced backlink tracking bot; https://serpstatbot.com/; abuse@serpstatbot.com)',
];
protected function setUp(): void
{
$this->configParams['do_not_track_ips'] = [self::DO_NOT_TRACK_IP];
$this->configParams['bot_helper_blocked_ip_addresses'] = [self::BOT_BLOCKED_IP];
$this->configParams['bot_helper_blocked_user_agents'] = self::BOT_BLOCKED_USER_AGENTS;
$this->configParams['site_url'] = 'https://mautic-cloud.local';
parent::setUp();
$this->pageHitRepository = $this->em->getRepository(Hit::class);
$this->logoutUser();
}
public function testItRegistersPageHitsWithFieldValues(): void
{
$requestParameters = [
'page_title' => $this->generateRandomUTF8String(512),
'page_language' => $this->generateRandomUTF8String(512),
'page_url' => 'https://some.page.url/test/'.$this->generateRandomUTF8String(512),
'counter' => 0,
'timezone_offset' => -120,
'resolution' => '2560x1440',
'platform' => 'MacOs',
'do_not_track' => 'false',
'mautic_device_id' => 'some_device_id',
];
$this->client->request(Request::METHOD_POST, '/mtc/event', $requestParameters);
/** @var Hit $pageHit */
$pageHit = $this->pageHitRepository->findOneBy([]);
Assert::assertInstanceOf(Hit::class, $pageHit);
Assert::assertStringStartsWith($pageHit->getUrlTitle(), $requestParameters['page_title']);
Assert::assertStringStartsWith($pageHit->getPageLanguage(), $requestParameters['page_language']);
Assert::assertStringStartsWith($pageHit->getUrl(), $requestParameters['page_url']);
}
public function generateRandomString(int $length): string
{
return substr(bin2hex(random_bytes($length)), 0, $length);
}
public function generateRandomUTF8String(int $length): string
{
$result = '';
for ($i = 0; $i < $length; ++$i) {
$codePoint = mt_rand(0x80, 0xFFFF);
$char = \IntlChar::chr($codePoint);
if (null !== $char && \IntlChar::isprint($char)) {
$result .= $char;
} else {
--$i;
}
}
return $result;
}
#[\PHPUnit\Framework\Attributes\DataProvider('pageHitBotScenariosProvider')]
public function testItNotRegistersPageHitsFromBot(string $trackingHash, string $sentBefore, string $userAgent, string $ipAddress, bool $isHit): void
{
$lead = new Lead();
$lead->setFirstname('Test Page Hit');
$this->em->persist($lead);
$email = new Email();
$email->setName('Email A');
$email->setSubject('Email A Subject');
$this->em->persist($email);
$this->em->flush();
$emailId = $email->getId();
$clickThrough = [
'source' => ['email', $emailId],
'email' => $emailId,
'stat' => $trackingHash,
'lead' => $lead->getId(),
'channel' => [
'email' => $emailId,
],
'mtc_redirect_destination' => 'https://some.page.url/test/redirect',
];
$requestParameters = [
'page_title' => $this->generateRandomString(50),
'page_language' => $this->generateRandomString(50),
'page_url' => 'https://some.page.url/test/'.$this->generateRandomString(50),
'counter' => 0,
'timezone_offset' => -120,
'resolution' => '2560x1440',
'platform' => 'MacOs',
'do_not_track' => 'false',
'mautic_device_id' => 'some_device_id',
'ct' => base64_encode(serialize($clickThrough)),
];
// Create Email Stat
$emailStat = new Stat();
$emailStat->setEmailAddress('lukas.sykora@acquia.com');
$emailStat->setTrackingHash($trackingHash);
$emailSendTime = new \DateTime();
$emailStat->setDateSent($emailSendTime->modify($sentBefore));
$this->em->persist($emailStat);
$this->em->flush();
// Send Request
$server = [
'HTTP_USER_AGENT' => $userAgent,
'REMOTE_ADDR' => $ipAddress,
];
$this->client->request(Request::METHOD_POST, '/mtc/event', $requestParameters, [], $server);
/** @var Hit $pageHit */
$pageHit = $this->pageHitRepository->findOneBy([]);
if ($isHit) {
Assert::assertInstanceOf(Hit::class, $pageHit);
Assert::assertStringStartsWith($pageHit->getUrlTitle(), $requestParameters['page_title']);
Assert::assertStringStartsWith($pageHit->getPageLanguage(), $requestParameters['page_language']);
Assert::assertStringStartsWith($pageHit->getUrl(), $requestParameters['page_url']);
} else {
Assert::assertNull($pageHit);
}
}
/**
* @return iterable<string, array<mixed>>
*/
public static function pageHitBotScenariosProvider(): iterable
{
// $trackingHash, $sentBefore, $userAgent, $ipAddress, $isHit
yield 'All good' => ['test_hash_bot_ratio_1', '-80 second', 'Mozilla/5.0', self::IP_NOT_IN_ANY_BLOCK_LIST, true];
yield 'Time and User' => ['test_hash_bot_ratio_2', '+80 second', 'AHC/2.1', self::IP_NOT_IN_ANY_BLOCK_LIST, false];
yield 'Time and IP' => ['test_hash_bot_ratio_3', '+80 second', 'Mozilla/5.0', self::BOT_BLOCKED_IP, false];
yield 'Permanently blocked IP' => ['test_hash_bot_ratio_4', '-80 second', 'Mozilla/5.0', self::DO_NOT_TRACK_IP, false];
yield 'Bot Blocked IP address only' => ['test_hash_bot_ratio_5', '-80 second', 'Mozilla/5.0', self::BOT_BLOCKED_IP, true];
yield 'Bot Blocked User Agent only' => ['test_hash_bot_ratio_6', '-80 second', 'AHC/2.1', self::IP_NOT_IN_ANY_BLOCK_LIST, true];
yield 'Time Only' => ['test_hash_bot_ratio_7', '+80 second', 'Mozilla/5.0', self::IP_NOT_IN_ANY_BLOCK_LIST, true];
yield 'Time and Bot User Agent and Bot IP' => ['test_hash_bot_ratio_8', '+80 second', 'AHC/2.1', self::BOT_BLOCKED_IP, false];
yield 'Bot User Agent and Bot IP' => ['test_hash_bot_ratio_9', '-80 second', 'AHC/2.1', self::BOT_BLOCKED_IP, false];
yield 'Permanently blocked User Agent' => ['test_hash_bot_ratio_10', '-80 second', 'MSNBOT', self::IP_NOT_IN_ANY_BLOCK_LIST2, false];
}
#[\PHPUnit\Framework\Attributes\DataProvider('pageHitBotScenariosProvider')]
public function testRedirect(string $trackingHash, string $sentBefore, string $userAgent, string $ipAddress, bool $isHit): void
{
$lead = new Lead();
$lead->setFirstname('Test Page Hit');
$this->em->persist($lead);
$email = new Email();
$email->setName('Email A');
$email->setSubject('Email A Subject');
$this->em->persist($email);
$this->em->flush();
$page = new Page();
$page->setTitle('Page A');
$page->setAlias('page_a');
$page->setCustomHtml('Page A');
$page->setRedirectUrl('http://mautic-cloud.local/page_a');
$this->em->persist($page);
$this->em->flush();
$emailId = $email->getId();
$clickThrough = [
'source' => ['email', $emailId],
'email' => $emailId,
'stat' => $trackingHash,
'lead' => $lead->getId(),
'channel' => [
'email' => $emailId,
],
'mtc_redirect_destination' => 'http://mautic-cloud.local/page_a',
];
// Create Email Stat
$emailStat = new Stat();
$emailStat->setEmailAddress('lukas.sykora@acquia.com');
$emailStat->setTrackingHash($trackingHash);
$emailSendTime = new \DateTime();
$emailStat->setDateSent($emailSendTime->modify($sentBefore));
$this->em->persist($emailStat);
$this->em->flush();
$redirectId = 'abc';
$redirect = new Redirect();
$redirect->setRedirectId($redirectId);
$redirect->setUrl('http://mautic-cloud.local/page_a');
$this->em->persist($redirect);
$this->em->flush();
$redirectModel = $this->getContainer()->get('mautic.page.model.redirect');
$redirectURL = $redirectModel->generateRedirectUrl($redirect, $clickThrough);
// Send Request
$server = [
'HTTP_USER_AGENT' => $userAgent,
'REMOTE_ADDR' => $ipAddress,
];
$this->client->request(Request::METHOD_GET, $redirectURL, [], [], $server);
/** @var Hit $pageHit */
$pageHit = $this->pageHitRepository->findOneBy([]);
if ($isHit) {
Assert::assertInstanceOf(Hit::class, $pageHit);
Assert::assertStringStartsWith($pageHit->getUrl(), $page->getRedirectUrl());
} else {
Assert::assertNull($pageHit);
}
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Helper;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Helper\PointActionHelper;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class PointActionHelperTest extends TestCase
{
/**
* @var MockObject|EntityManagerInterface
*/
private $entityManager;
/**
* @var MockObject|HitRepository
*/
private $hitRepository;
/**
* @var MockObject|Lead
*/
private $lead;
/**
* @var MockObject|Hit
*/
private $eventDetails;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->hitRepository = $this->createMock(HitRepository::class);
$this->lead = $this->createMock(Lead::class);
$this->eventDetails = $this->createMock(Hit::class);
$this->eventDetails->method('getLead')->willReturn($this->lead);
$this->entityManager->method('getRepository')->willReturn($this->hitRepository);
}
/**
* @param array<string, mixed> $action
*/
#[\PHPUnit\Framework\Attributes\DataProvider('urlHitsActionDataProvider')]
public function testValidateUrlPageHitsAction(array $action, bool $expectedResult): void
{
$this->eventDetails->method('getUrl')->willReturn('https://example.com/ppk');
$this->hitRepository->method('getDwellTimesForUrl')->willReturn([
'sum' => 0,
'min' => 0,
'max' => 0,
'average' => 0.0,
'count' => 1,
]);
$this->hitRepository->expects($this->never())->method('getLatestHit');
$pointActionHelper = new PointActionHelper($this->entityManager);
$result = $pointActionHelper->validateUrlHit($this->eventDetails, $action);
$this->assertSame($expectedResult, $result);
}
/**
* @return array<string, array<int, mixed>>
*/
public static function urlHitsActionDataProvider(): array
{
return [
'url_matches_first_hit' => [
[
'id' => 2,
'type' => 'url.hit',
'name' => 'Hit page',
'properties' => [
'page_url' => 'https://example.com/ppk',
'page_hits' => 1,
'accumulative_time_unit' => 'H',
'accumulative_time' => 0,
'returns_within_unit' => 'H',
'returns_within' => 0,
'returns_after_unit' => 'H',
'returns_after' => 0,
],
'points' => 5,
],
true,
],
'url_does_not_match' => [
[
'id' => 3,
'type' => 'url.hit',
'name' => 'Invalid URL',
'properties' => [
'page_url' => 'https://example.com/invalid',
'page_hits' => 1,
'accumulative_time_unit' => 'H',
'accumulative_time' => 0,
'returns_within_unit' => 'H',
'returns_within' => 0,
'returns_after_unit' => 'H',
'returns_after' => 0,
],
'points' => 5,
],
false,
],
];
}
/**
* @param array<string, mixed> $action
*/
#[\PHPUnit\Framework\Attributes\DataProvider('returnWithinActionDataProvider')]
public function testValidateUrlReturnWithinAction(array $action, bool $expectedResult): void
{
$this->eventDetails->method('getUrl')->willReturn('https://example.com/test/');
$this->hitRepository->method('getDwellTimesForUrl')->willReturn([
'sum' => 0,
'min' => 0,
'max' => 0,
'average' => 0.0,
'count' => 1,
]);
$currentTimestamp = time();
$threeHoursAgoTimestamp = $currentTimestamp - (3 * 3600);
$latestHit = new \DateTime();
$latestHit->setTimestamp($threeHoursAgoTimestamp);
$this->hitRepository->method('getLatestHit')->willReturn($latestHit);
$pointActionHelper = new PointActionHelper($this->entityManager);
$result = $pointActionHelper->validateUrlHit($this->eventDetails, $action);
$this->assertSame($expectedResult, $result);
}
/**
* @return array<string, array<int, mixed>>
*/
public static function returnWithinActionDataProvider(): array
{
return [
'valid_return_within' => [
[
'id' => 1,
'type' => 'url.hit',
'name' => 'Test return within',
'properties' => [
'page_url' => 'https://example.com/test/',
'page_hits' => null,
'accumulative_time_unit' => 'H',
'accumulative_time' => 0,
'returns_within_unit' => 'H',
'returns_within' => 14400, // 4 hours in seconds
'returns_after_unit' => 'H',
'returns_after' => 0,
],
'points' => 3,
],
true,
],
'invalid_return_within' => [
[
'id' => 4,
'type' => 'url.hit',
'name' => 'Invalid Return Within',
'properties' => [
'page_url' => 'https://example.com/test/',
'page_hits' => null,
'accumulative_time_unit' => 'H',
'accumulative_time' => 0,
'returns_within_unit' => 'H',
'returns_within' => 3600, // 1 hour in seconds
'returns_after_unit' => 'H',
'returns_after' => 0,
],
'points' => 3,
],
false,
],
];
}
}

View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace Mautic\PageBundle\Tests\Model;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Helper\ClickthroughHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Tests\PageTestAbstract;
use Symfony\Component\HttpFoundation\Request;
class PageModelTest extends PageTestAbstract
{
public function testUtf8CharsInTitleWithTransletirationEnabled(): void
{
$providedTitle = '你好,世界';
$expectedTitle = 'ni hao, shi jie';
$hit = new Hit();
$page = new Page();
$request = new Request();
$contact = new Lead();
$pageModel = $this->getPageModel();
$hit->setIpAddress(new IpAddress());
$hit->setQuery(['page_title' => $providedTitle]);
$pageModel->processPageHit($hit, $page, $request, $contact, false);
$this->assertSame($expectedTitle, $hit->getUrlTitle());
$this->assertSame(['page_title' => $expectedTitle], $hit->getQuery());
}
public function testUtf8CharsInTitleWithTransletirationDisabled(): void
{
$providedTitle = '你好,世界';
$expectedTitle = '你好,世界';
$hit = new Hit();
$page = new Page();
$request = new Request();
$contact = new Lead();
$pageModel = $this->getPageModel(false);
$hit->setIpAddress(new IpAddress());
$hit->setQuery(['page_title' => $providedTitle]);
$pageModel->processPageHit($hit, $page, $request, $contact, false);
$this->assertSame($expectedTitle, $hit->getUrlTitle());
$this->assertSame(['page_title' => $expectedTitle], $hit->getQuery());
}
public function testGenerateUrlWhenCalledReturnsValidUrl(): void
{
$page = new Page();
$page->setAlias('this-is-a-test');
$pageModel = $this->getPageModel();
$this->router->expects($this->once())
->method('generate')
->willReturnCallback(
function (string $route, array $routeParams, int $referenceType) {
$this->assertSame('mautic_page_public', $route);
$this->assertSame(['slug' => 'this-is-a-test'], $routeParams);
$this->assertSame(0, $referenceType);
return '/'.$routeParams['slug'];
}
);
$url = $pageModel->generateUrl($page);
$this->assertStringContainsString('/this-is-a-test', $url);
}
public function testUrlTitleFallbacksToPageTitleWhenNotInQuery(): void
{
$providedTitle = '你好,世界';
$expectedTitle = 'ni hao, shi jie';
$hit = new Hit();
$page = new Page();
$request = new Request();
$contact = new Lead();
$ipAddress = new IpAddress();
$pageModel = $this->getPageModel();
$page->setTitle($providedTitle);
$hit->setIpAddress($ipAddress);
$hit->setQuery([]);
$pageModel->processPageHit($hit, $page, $request, $contact, false);
$this->assertSame($expectedTitle, $hit->getUrlTitle());
}
public function testCleanQueryWhenCalledReturnsSafeAndValidData(): void
{
$pageModel = $this->getPageModel();
$pageModelReflection = new \ReflectionClass($pageModel::class);
$cleanQueryMethod = $pageModelReflection->getMethod('cleanQuery');
$cleanQueryMethod->setAccessible(true);
$res = $cleanQueryMethod->invokeArgs($pageModel, [
[
'page_title' => 'Mautic & PHP',
'page_url' => 'http://mautic.com/page/test?hello=world&lorem=ipsum&q=this%20has%20spaces',
'page_language' => 'en',
],
]);
$this->assertEquals($res, [
'page_title' => 'Mautic &#38; PHP',
'page_url' => 'http://mautic.com/page/test?hello=world&lorem=ipsum&q=this%20has%20spaces',
'page_language' => 'en',
]);
}
/**
* Test getHitQuery when the hit is a Request
* (e.g. POST Ajax or Landingpage hit).
*/
public function testGetHitQueryRequest(): void
{
$pageModel = $this->getPageModel();
foreach ($this->getQueryParams() as $params) {
$request = new Request($params);
$query = $pageModel->getHitQuery($request);
$this->assertUtmQuery($query);
}
}
/**
* Test getHitQuery when the hit is a Redirect.
*/
public function testGetHitQueryRedirect(): void
{
$pageModel = $this->getPageModel();
$request = new Request();
$redirect = new Redirect();
foreach ($this->getQueryParams() as $params) {
$redirect->setUrl($params['page_url']);
$query = $pageModel->getHitQuery($request, $redirect);
$this->assertUtmQuery($query);
}
}
/**
* This test is somewhat synthetic to test the missing $query['ct'].
*/
public function testNoClickThroughInQuery(): void
{
$redirectUrl = '/somewhat';
$pageModel = $this->getPageModel();
$ipAddress = $this->createMock(IpAddress::class);
$ipAddress->method('isTrackable')->willReturn(true);
$this->security->method('isAnonymous')->willReturn(true);
$this->ipLookupHelper->method('getIpAddress')->willReturn($ipAddress);
$this->companyModel->method('fetchCompanyFields')->willReturn([]);
$redirect = $this->createMock(Redirect::class);
$redirect->method('getUrl')->willReturn($redirectUrl);
$this->contactRequestHelper->expects($this->once())
->method('getContactFromQuery')
->with(['page_url' => $redirectUrl])
->willReturn(null);
$result = $pageModel->hitPage($redirect, new Request());
self::assertFalse($result);
}
private function assertUtmQuery(array $query): void
{
$this->assertArrayHasKey('utm_source', $query, 'utm_source not found');
$this->assertArrayHasKey('utm_medium', $query, 'utm_medium not found');
$this->assertArrayHasKey('utm_campaign', $query, 'utm_campaign not found');
$this->assertArrayHasKey('utm_content', $query, 'utm_content not found');
// evaluate all utm tags that they contain the key name in the value
foreach ($query as $key => $value) {
if (str_contains($key, 'utm_')) {
$this->assertNotFalse(strpos($value, (string) $key), sprintf('%s not found in %s', $key, $value));
}
}
}
private function getQueryParams(): array
{
$utm = [
'utm_source' => 'test-utm_source',
'utm_medium' => 'test-utm_medium',
'utm_campaign'=> 'test-utm_campaign',
'utm_content' => 'test-utm_content',
];
$querystring = '';
foreach ($utm as $key => $value) {
$querystring .= sprintf('&%s=%s', $key, $value);
}
$ctParams = [
'source' => ['email', '4'],
'email' => 4,
'stat' => '5f5dedc3b0dc0366144010',
'lead' => 2,
'channel' => [
'email' => 4,
],
];
$ct = ClickthroughHelper::encodeArrayForUrl($ctParams);
$params = [[
'page_title' => 'Testpage',
'page_language' => 'en-GB',
'page_referrer' => '',
'page_url' => sprintf('https://www.domain.com/testpage/?%s', $querystring),
'counter' => 0,
'mautic_device_id'=> 'nowvkqdf6113236eokcg7qs',
'resolution' => '1792x1120',
'timezone_offset' => -120,
'platform' => 'MacIntel',
'do_not_track' => 1,
'adblock' => false,
'fingerprint' => 'fec25ab2d659c4153c7f1d5724841132',
], [
'page_title' => 'Testpage Special Chars',
'page_language' => 'en-GB',
'page_referrer' => '',
'page_url' => 'https://www.domain.com/testpage/?utm_source=t%C3%A9%C3%A0%C3%A8st-utm_source&utm_medium=t%C3%A4%C3%B6ust-utm_medium&utm_campaign=te+%20%C2%B0st-utm_campaign&utm_content=t%E4%BD%A0%E5%A5%BDt-utm_content',
'counter' => 0,
'mautic_device_id'=> 'nowvkqdf6113236eokcg7qs',
'resolution' => '1792x1120',
'timezone_offset' => -120,
'platform' => 'MacIntel',
'do_not_track' => 1,
'adblock' => false,
'fingerprint' => 'fec25ab2d659c4153c7f1d5724841132',
], [
'page_title' => 'Testpage With Encoded Params',
'page_language' => 'en-GB',
'page_referrer' => '',
'page_url' => sprintf('https://www.domain.com/testpage/?ct=%s&%s', $ct, $querystring),
'counter' => 0,
'mautic_device_id'=> 'nowvkqdf6113236eokcg7qs',
'resolution' => '1792x1120',
'timezone_offset' => -120,
'platform' => 'MacIntel',
'do_not_track' => 1,
'adblock' => false,
'fingerprint' => 'fec25ab2d659c4153c7f1d5724841132',
]];
return $params;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Mautic\PageBundle\Tests\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Shortener\Shortener;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Event\RedirectGenerationEvent;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\PageEvents;
use Mautic\PageBundle\Tests\PageTestAbstract;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\EventDispatcher\EventDispatcher;
class RedirectModelTest extends PageTestAbstract
{
public function testCreateRedirectEntityWhenCalledReturnsRedirect(): void
{
$redirectModel = $this->getRedirectModel();
$entity = $redirectModel->createRedirectEntity('http://some-url.com');
$this->assertInstanceOf(Redirect::class, $entity);
}
public function testGenerateRedirectUrlWhenCalledReturnsValidUrl(): void
{
$redirect = new Redirect();
$redirect->setUrl('http://some-url.com');
$redirect->setRedirectId('redirect-id');
$redirectModel = $this->getRedirectModel();
$url = $redirectModel->generateRedirectUrl($redirect);
$this->assertStringContainsString($url, 'http://some-url.com');
}
public function testRedirectGenerationEvent(): void
{
$shortener = $this->createMock(Shortener::class);
$dispatcher = new EventDispatcher();
$url = 'https://mautic.org';
$clickthrough = ['foo' => 'bar'];
$router = $this->createMock(Router::class);
$router->expects($this->exactly(2))
->method('generate')
->willReturn($url);
$model = new RedirectModel(
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$dispatcher,
$router,
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class),
$shortener
);
$redirect = new Redirect();
$redirect->setUrl($url);
// URL should just have foo = bar in the CT
$url = $model->generateRedirectUrl($redirect, $clickthrough);
$this->assertEquals('https://mautic.org?ct=YToxOntzOjM6ImZvbyI7czozOiJiYXIiO30%3D', $url);
// Add the listener to append something else to the CT
$dispatcher->addListener(
PageEvents::ON_REDIRECT_GENERATE,
function (RedirectGenerationEvent $event): void {
$event->setInClickthrough('bar', 'foo');
}
);
$url = $model->generateRedirectUrl($redirect, $clickthrough);
$this->assertEquals('https://mautic.org?ct=YToyOntzOjM6ImZvbyI7czozOiJiYXIiO3M6MzoiYmFyIjtzOjM6ImZvbyI7fQ%3D%3D', $url);
}
}

View File

@@ -0,0 +1,688 @@
<?php
namespace Mautic\PageBundle\Tests\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\Model\TrackableModel;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[\PHPUnit\Framework\Attributes\CoversClass(TrackableModel::class)]
class TrackableModelTest extends TestCase
{
#[\PHPUnit\Framework\Attributes\TestDox('Test that content is detected as HTML')]
public function testHtmlIsDetectedInContent(): void
{
$mockRedirectModel = $this->createMock(RedirectModel::class);
$mockLeadFieldRepository = $this->createMock(LeadFieldRepository::class);
$mockModel = $this->getMockBuilder(TrackableModel::class)
->setConstructorArgs([
$mockRedirectModel,
$mockLeadFieldRepository,
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class),
])
->onlyMethods(['getDoNotTrackList', 'getEntitiesFromUrls', 'createTrackingTokens', 'extractTrackablesFromHtml'])
->getMock();
$mockModel->expects($this->once())
->method('getEntitiesFromUrls')
->willReturn([]);
$mockModel->expects($this->once())
->method('getDoNotTrackList')
->willReturn([]);
$mockModel->expects($this->once())
->method('extractTrackablesFromHtml')
->willReturn(
[
'',
[],
]
);
$mockModel->expects($this->once())
->method('createTrackingTokens')
->willReturn([]);
[$content, $trackables] = $mockModel->parseContentForTrackables(
$this->generateContent('https://foo-bar.com', 'html'),
[],
'email',
1
);
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that content is detected as plain text')]
public function testPlainTextIsDetectedInContent(): void
{
$mockRedirectModel = $this->createMock(RedirectModel::class);
$mockLeadFieldRepository = $this->createMock(LeadFieldRepository::class);
$mockModel = $this->getMockBuilder(TrackableModel::class)
->setConstructorArgs([
$mockRedirectModel,
$mockLeadFieldRepository,
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class),
])
->onlyMethods(['getDoNotTrackList', 'getEntitiesFromUrls', 'createTrackingTokens', 'extractTrackablesFromText'])
->getMock();
$mockModel->expects($this->once())
->method('getDoNotTrackList')
->willReturn([]);
$mockModel->expects($this->once())
->method('getEntitiesFromUrls')
->willReturn([]);
$mockModel->expects($this->once())
->method('extractTrackablesFromText')
->willReturn(
[
'',
[],
]
);
$mockModel->expects($this->once())
->method('createTrackingTokens')
->willReturn([]);
[$content, $trackables] = $mockModel->parseContentForTrackables(
$this->generateContent('https://foo-bar.com', 'text'),
[],
'email',
1
);
}
#[\PHPUnit\Framework\Attributes\DataProvider('trackMapProvider')]
#[\PHPUnit\Framework\Attributes\TestDox('Test that a standard link with a standard query is parsed correctly')]
public function testStandardLinkWithStandardQuery(?bool $useMap): void
{
$url = 'https://foo-bar.com?foo=bar&amp;one=two&three=four&amp;five=six';
$model = $this->getModel();
if (null !== $useMap) {
$emailContent = $this->generateContent($url, 'html', false, $useMap);
} else {
$emailContent = $this->generateContent($url, 'html', false, true)
.$this->generateContent($url, 'html', false, false);
}
[$content, $trackables] = $model->parseContentForTrackables(
$emailContent,
[],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
Assert::assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
Assert::assertArrayHasKey($match[0], $trackables);
// Assert that exactly one trackable found
Assert::assertCount(1, $trackables);
// Assert that the URL redirect equals $url
$redirect = $trackables[$match[0]]->getRedirect();
Assert::assertEquals(str_replace('&amp;', '&', $url), $redirect->getUrl());
}
#[\PHPUnit\Framework\Attributes\DataProvider('trackMapProvider')]
#[\PHPUnit\Framework\Attributes\TestDox('Test that a standard link without a query parses correctly')]
public function testStandardLinkWithoutQuery(?bool $useMap): void
{
$url = 'https://foo-bar.com';
$model = $this->getModel();
if (null !== $useMap) {
$emailContent = $this->generateContent($url, 'html', false, $useMap);
} else {
$emailContent = $this->generateContent($url, 'html', false, true)
.$this->generateContent($url, 'html', false, false);
}
[$content, $trackables] = $model->parseContentForTrackables(
$emailContent,
[],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
Assert::assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
Assert::assertArrayHasKey($match[0], $trackables);
// Assert that exactly one trackable found
Assert::assertCount(1, $trackables);
// Assert that the URL redirect equals $url
$redirect = $trackables[$match[0]]->getRedirect();
Assert::assertEquals($url, $redirect->getUrl());
}
#[\PHPUnit\Framework\Attributes\DataProvider('trackMapProvider')]
#[\PHPUnit\Framework\Attributes\TestDox('Test that a standard link with a tokenized query parses correctly')]
public function testStandardLinkWithTokenizedQuery(?bool $useMap): void
{
$url = 'https://foo-bar.com?foo={contactfield=bar}&bar=foo';
$model = $this->getModel();
if (null !== $useMap) {
$emailContent = $this->generateContent($url, 'html', false, $useMap);
} else {
$emailContent = $this->generateContent($url, 'html', false, true)
.$this->generateContent($url, 'html', false, false);
}
[$content, $trackables] = $model->parseContentForTrackables(
$emailContent,
[
'{contactfield=bar}' => '',
],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
Assert::assertTrue((bool) $tokenFound, $content);
// Assert that exactly one trackable found
Assert::assertCount(1, $trackables);
// Assert the Trackable exists
Assert::assertArrayHasKey('{trackable='.$match[1].'}', $trackables);
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that a token used in place of a URL is parsed properly')]
public function testTokenizedDomain(): void
{
$url = 'http://{contactfield=foo}.org';
$model = $this->getModel();
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'html'),
[
'{contactfield=foo}' => 'mautic',
],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
$this->assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
$this->assertArrayHasKey('{trackable='.$match[1].'}', $trackables);
}
public function testTokenizedHostWithScheme(): void
{
$url = '{contactfield=foo}';
$model = $this->getModel();
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'html'),
[
'{contactfield=foo}' => 'https://mautic.org',
],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
$this->assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
$this->assertArrayHasKey('{trackable='.$match[1].'}', $trackables);
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that a token used in place of a URL is parsed')]
public function testTokenizedHostWithQuery(): void
{
$url = 'http://{contactfield=foo}.com?foo=bar';
$model = $this->getModel();
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'html'),
[
'{contactfield=foo}' => '',
],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
$this->assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
$this->assertArrayHasKey('{trackable='.$match[1].'}', $trackables);
}
public function testTokenizedHostWithTokenizedQuery(): void
{
$url = 'http://{contactfield=foo}.com?foo={contactfield=bar}';
$model = $this->getModel();
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'html'),
[
'{contactfield=foo}' => '',
'{contactfield=bar}' => '',
],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
$this->assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
$this->assertArrayHasKey('{trackable='.$match[1].'}', $trackables);
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that tokens that are supposed to be ignored are')]
public function testIgnoredTokensAreNotConverted(): void
{
$url = 'https://{unsubscribe_url}';
$model = $this->getModel(['{unsubscribe_url}']);
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'html'),
[
'{unsubscribe_url}' => 'https://domain.com/email/unsubscribe/xxxxxxx',
],
'email',
1
);
$this->assertEmpty($trackables, $content);
$this->assertFalse(strpos($content, $url), 'https:// should have been stripped from the token URL');
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that tokens that are supposed to be ignored are')]
public function testUnsupportedTokensAreNotConverted(): void
{
$url = '{random_token}';
$model = $this->getModel();
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'text'),
[
'{unsubscribe_url}' => 'https://domain.com/email/unsubscribe/xxxxxxx',
],
'email',
1
);
$this->assertEmpty($trackables, $content);
}
public function testTokenWithDefaultValueInPlaintextWillCountAsOne(): void
{
$url = '{contactfield=website|https://mautic.org}';
$model = $this->getModel();
$inputContent = $this->generateContent($url, 'text');
[$content, $trackables] = $model->parseContentForTrackables(
$inputContent,
[
'{contactfield=website}' => 'https://mautic.org/about-us',
],
'email',
1
);
$tokenFound = preg_match('/\{trackable=(.*?)\}/', $content, $match);
// Assert that a trackable token exists
$this->assertTrue((bool) $tokenFound, $content);
// Assert the Trackable exists
$trackableKey = '{trackable='.$match[1].'}';
$this->assertArrayHasKey('{trackable='.$match[1].'}', $trackables);
$this->assertEquals(1, count($trackables));
$this->assertEquals('{contactfield=website|https://mautic.org}', $trackables[$trackableKey]->getRedirect()->getUrl());
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that a URL injected into the do not track list is not converted')]
public function testIgnoredUrlDoesNotCrash(): void
{
$url = 'https://domain.com';
$model = $this->getModel([$url]);
[$content, $trackables] = $model->parseContentForTrackables(
$this->generateContent($url, 'html'),
[],
'email',
1
);
$this->assertTrue(str_contains($content, $url), $content);
}
#[\PHPUnit\Framework\Attributes\DataProvider('trackMapProvider')]
#[\PHPUnit\Framework\Attributes\TestDox('Test that a token used in place of a URL is not parsed')]
public function testTokenAsHostIsConvertedToTrackableToken(?bool $useMap): void
{
$url = 'http://{pagelink=1}';
$model = $this->getModel();
if (null !== $useMap) {
$emailContent = $this->generateContent($url, 'html', false, $useMap);
} else {
$emailContent = $this->generateContent($url, 'html', false, true)
.$this->generateContent($url, 'html', false, false);
}
[$content, $trackables] = $model->parseContentForTrackables(
$emailContent,
[
'{pagelink=1}' => 'http://foo-bar.com',
],
'email',
1
);
$token = array_key_first($trackables);
Assert::assertNotEmpty($trackables, $content);
Assert::assertStringContainsString($token, $content);
// Assert that exactly one trackable found
Assert::assertCount(1, $trackables);
}
#[\PHPUnit\Framework\Attributes\DataProvider('trackMapProvider')]
#[\PHPUnit\Framework\Attributes\TestDox('Test that a URLs with same base or correctly replaced')]
public function testUrlsWithSameBaseAreReplacedCorrectly(?bool $useMap): void
{
$urls = [
'https://foo-bar.com',
'https://foo-bar.com?foo=bar',
'https://FOO-bar.com/bar',
];
$model = $this->getModel();
if (null !== $useMap) {
$emailContent = $this->generateContent($urls, 'html', false, $useMap);
} else {
$emailContent = $this->generateContent($urls, 'html', false, true)
.$this->generateContent($urls, 'html', false, false);
}
[$content, $trackables] = $model->parseContentForTrackables(
$emailContent,
[],
'email',
1
);
// Assert that both trackables found
Assert::assertCount(3, $trackables);
foreach ($trackables as $redirectId => $trackable) {
// If the shared base was correctly parsed, all generated tokens will be in the content
Assert::assertNotFalse(strpos($content, (string) $redirectId), $content);
}
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that css images are not converted if there are no links')]
public function testCssUrlsAreNotConvertedIfThereAreNoLinks(): void
{
$model = $this->getModel();
[$content, $trackables] = $model->parseContentForTrackables(
'<style> .mf-modal { background-image: url(\'https://www.mautic.org/wp-content/uploads/2014/08/iTunesArtwork.png\'); } </style>',
[],
'email',
1
);
$this->assertEmpty($trackables);
}
#[\PHPUnit\Framework\Attributes\TestDox('Tests that URLs in the plaintext does not contaminate HTML')]
public function testPlainTextDoesNotContaminateHtml(): void
{
$model = $this->getModel();
$html = <<<TEXT
Hi {contactfield=firstname},
<br />
Come to our office in {contactfield=city}!
<br />
John Smith<br />
VP of Sales<br />
https://plaintexttest.io
TEXT;
$plainText = strip_tags($html);
$combined = [$html, $plainText];
[$content, $trackables] = $model->parseContentForTrackables(
$combined,
[],
'email',
1
);
$this->assertCount(1, $trackables);
// No links so no trackables
$this->assertEquals($html, $content[0]);
$token = array_key_first($trackables);
self::assertNotNull($token);
$this->assertEquals(str_replace('https://plaintexttest.io', $token, $plainText), $content[1]);
}
#[\PHPUnit\Framework\Attributes\TestDox('Tests that URL based contact fields are found in plain text')]
public function testPlainTextFindsUrlContactFields(): void
{
$model = $this->getModel([], ['website']);
$html = <<<TEXT
Hi {contactfield=firstname},
<br />
Come to our office in {contactfield=city}!
<br />
John Smith<br />
VP of Sales<br />
{contactfield=website}
TEXT;
$plainText = strip_tags($html);
$combined = [$html, $plainText];
[$content, $trackables] = $model->parseContentForTrackables(
$combined,
[],
'email',
1
);
$this->assertCount(1, $trackables);
// No links so no trackables
$this->assertEquals($html, $content[0]);
$token = array_key_first($trackables);
self::assertNotNull($token);
$this->assertEquals(str_replace('{contactfield=website}', $token, $plainText), $content[1]);
}
/**
* @param array $doNotTrack
* @param array $urlFieldsForPlaintext
*
* @return TrackableModel|\PHPUnit\Framework\MockObject\MockObject
*/
protected function getModel($doNotTrack = [], $urlFieldsForPlaintext = [])
{
// Add default DoNotTrack
$doNotTrack = array_merge(
$doNotTrack,
[
'{webview_url}',
'{unsubscribe_url}',
'{trackable=(.*?)}',
]
);
$mockRedirectModel = $this->createMock(RedirectModel::class);
$mockLeadFieldRepository = $this->createMock(LeadFieldRepository::class);
$mockModel = $this->getMockBuilder(TrackableModel::class)
->setConstructorArgs([
$mockRedirectModel,
$mockLeadFieldRepository,
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class),
])
->onlyMethods(['getDoNotTrackList', 'getEntitiesFromUrls', 'getContactFieldUrlTokens'])
->getMock();
$mockModel->expects($this->once())
->method('getDoNotTrackList')
->willReturn($doNotTrack);
$mockModel->expects($this->any())
->method('getEntitiesFromUrls')
->willReturnCallback(
function ($trackableUrls, $channel, $channelId) {
$entities = [];
foreach ($trackableUrls as $url) {
$entities[$url] = $this->getTrackableEntity($url);
}
return $entities;
}
);
$mockModel->expects($this->any())
->method('getContactFieldUrlTokens')
->willReturn($urlFieldsForPlaintext);
return $mockModel;
}
/**
* @return Trackable
*/
protected function getTrackableEntity($url)
{
$redirect = new Redirect();
$redirect->setUrl($url);
$redirect->setRedirectId();
$trackable = new Trackable();
$trackable->setChannel('email')
->setChannelId(1)
->setRedirect($redirect)
->setHits(random_int(1, 10))
->setUniqueHits(random_int(1, 10));
return $trackable;
}
/**
* @param array<int, string>|string $urls
*/
protected function generateContent($urls, string $type, bool $doNotTrack = false, bool $useMap = false): string
{
$content = '';
if (!is_array($urls)) {
$urls = [$urls];
}
foreach ($urls as $url) {
if ('html' === $type) {
$dnc = ($doNotTrack) ? ' mautic:disable-tracking' : '';
if ($useMap) {
$content .= <<<CONTENT
ABC123 321ABC
ABC123 <map><area href="$url"$dnc alt="alt" /></map> 321ABC
CONTENT;
} else {
$content .= <<<CONTENT
ABC123 321ABC
ABC123 <a href="$url"$dnc>$url</a> 321ABC
CONTENT;
}
} else {
$content .= <<<CONTENT
ABC123 321ABC
ABC123 $url 321ABC
CONTENT;
}
}
return $content;
}
/**
* @return array<array<bool|null>> Use null to include both <a> and <map> tags
*/
public static function trackMapProvider(): array
{
return [
[true],
[false],
[null],
];
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Mautic\PageBundle\Tests\Model;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\Tracking404Model;
class Tracking404ModelTest extends \PHPUnit\Framework\TestCase
{
/**
* @var ContactTracker|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $mockContactTracker;
/**
* @var CoreParametersHelper|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $mockCoreParametersHelper;
/**
* @var PageModel|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $mockPageModel;
private Lead $lead;
public function setUp(): void
{
parent::setUp();
$this->mockCoreParametersHelper = $this->createMock(CoreParametersHelper::class);
$this->mockContactTracker = $this->createMock(ContactTracker::class);
$this->mockPageModel = $this->createMock(PageModel::class);
$this->lead = new Lead();
}
public function testIsTrackableIfTracking404OptionEnabled(): void
{
$this->mockCoreParametersHelper->expects($this->once())
->method('get')
->with('do_not_track_404_anonymous')
->willReturn(true);
$tracking404Model = new Tracking404Model($this->mockCoreParametersHelper, $this->mockContactTracker, $this->mockPageModel);
$this->assertFalse($tracking404Model->isTrackable());
}
public function testIsTrackableIfTracking404OptionDisable(): void
{
$this->mockCoreParametersHelper->expects($this->once())
->method('get')
->with('do_not_track_404_anonymous')
->willReturn(false);
$tracking404Model = new Tracking404Model($this->mockCoreParametersHelper, $this->mockContactTracker, $this->mockPageModel);
$this->assertTrue($tracking404Model->isTrackable());
}
public function testIsTrackableForIdentifiedContacts(): void
{
$this->mockCoreParametersHelper->expects($this->once())
->method('get')
->with('do_not_track_404_anonymous')
->willReturn(true);
$this->lead->setFirstname('identified');
$this->mockContactTracker->expects($this->any())
->method('getContactByTrackedDevice')
->willReturn($this->lead);
$tracking404Model = new Tracking404Model($this->mockCoreParametersHelper, $this->mockContactTracker, $this->mockPageModel);
$this->assertTrue($tracking404Model->isTrackable());
}
public function testIsTrackableForAnonymouse(): void
{
$this->mockCoreParametersHelper->expects($this->once())
->method('get')
->with('do_not_track_404_anonymous')
->willReturn(true);
$this->mockContactTracker->expects($this->any())
->method('getContactByTrackedDevice')
->willReturn($this->lead);
$tracking404Model = new Tracking404Model($this->mockCoreParametersHelper, $this->mockContactTracker, $this->mockPageModel);
$this->assertFalse($tracking404Model->isTrackable());
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Mautic\PageBundle\Tests;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Shortener\Shortener;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\EmailBundle\Helper\BotRatioHelper;
use Mautic\LeadBundle\Helper\ContactRequestHelper;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\DeviceTracker;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\PageRepository;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\Model\TrackableModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class PageTestAbstract extends TestCase
{
protected static $mockId = 123;
protected static $mockName = 'Mock test name';
protected string $mockTrackingId;
/**
* @var Router|MockObject
*/
protected $router;
protected CorePermissions&MockObject $security;
protected IpLookupHelper&MockObject $ipLookupHelper;
protected ContactRequestHelper&MockObject $contactRequestHelper;
protected CompanyModel&MockObject $companyModel;
protected function setUp(): void
{
$this->mockTrackingId = hash('sha1', uniqid(mt_rand(), true));
}
/**
* @return PageModel
*/
protected function getPageModel($transliterationEnabled = true)
{
$cookieHelper = $this->createMock(CookieHelper::class);
$this->router = $this->createMock(Router::class);
$this->ipLookupHelper = $this->createMock(IpLookupHelper::class);
$leadModel = $this->createMock(LeadModel::class);
$leadFieldModel = $this->createMock(FieldModel::class);
$redirectModel = $this->getRedirectModel();
$this->companyModel = $this->createMock(CompanyModel::class);
$trackableModel = $this->createMock(TrackableModel::class);
$dispatcher = $this->createMock(EventDispatcher::class);
$translator = $this->createMock(Translator::class);
$entityManager = $this->createMock(EntityManager::class);
$pageRepository = $this->createMock(PageRepository::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$hitRepository = $this->createMock(HitRepository::class);
$userHelper = $this->createMock(UserHelper::class);
$messageBus = $this->createMock(MessageBus::class);
$contactTracker = $this->createMock(ContactTracker::class);
$this->contactRequestHelper = $this->createMock(ContactRequestHelper::class);
$contactTracker->expects($this
->any())
->method('getContact')
->willReturn(['id' => self::$mockId, 'name' => self::$mockName]);
$entityManager->expects($this
->any())
->method('getRepository')
->willReturnMap(
[
[\Mautic\PageBundle\Entity\Page::class, $pageRepository],
[\Mautic\PageBundle\Entity\Hit::class, $hitRepository],
]
);
$coreParametersHelper->expects($this->any())
->method('get')
->with('transliterate_page_title')
->willReturn($transliterationEnabled);
$deviceTrackerMock = $this->createMock(DeviceTracker::class);
$statRepositoryMock = $this->createMock(StatRepository::class);
$botRatioHelperMock = $this->createMock(BotRatioHelper::class);
$pageModel = new PageModel(
$cookieHelper,
$this->ipLookupHelper,
$leadModel,
$leadFieldModel,
$redirectModel,
$trackableModel,
$messageBus,
$this->companyModel,
$deviceTrackerMock,
$contactTracker,
$coreParametersHelper,
$this->contactRequestHelper,
$entityManager,
$this->security = $this->createMock(CorePermissions::class),
$dispatcher,
$this->router,
$translator,
$userHelper,
$this->createMock(LoggerInterface::class),
$statRepositoryMock,
$botRatioHelperMock
);
return $pageModel;
}
/**
* @return RedirectModel
*/
protected function getRedirectModel()
{
$shortener = $this->createMock(Shortener::class);
$mockRedirectModel = $this->getMockBuilder(RedirectModel::class)
->setConstructorArgs([
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class),
$shortener,
])
->onlyMethods(['createRedirectEntity', 'generateRedirectUrl'])
->getMock();
$mockRedirect = $this->createMock(\Mautic\PageBundle\Entity\Redirect::class);
$mockRedirectModel->expects($this->any())
->method('createRedirectEntity')
->willReturn($mockRedirect);
$mockRedirectModel->expects($this->any())
->method('generateRedirectUrl')
->willReturn('http://some-url.com');
return $mockRedirectModel;
}
}