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,883 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\RoleRepository;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Entity\UserRepository;
use MauticPlugin\MauticTagManagerBundle\Entity\Tag;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
class AjaxControllerFunctionalTest extends MauticMysqlTestCase
{
protected function beforeBeginTransaction(): void
{
$this->resetAutoincrement([
'leads',
'campaigns',
]);
}
public function testToggleLeadCampaignAction(): void
{
$campaign = $this->createCampaign();
$contact = $this->createContact('blabla@contact.email');
// Ensure there is no member for campaign 1 yet.
$this->assertSame([], $this->getMembersForCampaign($campaign->getId()));
// Create the member now.
$payload = [
'action' => 'lead:toggleLeadCampaign',
'leadId' => $contact->getId(),
'campaignId' => $campaign->getId(),
'campaignAction' => 'add',
];
$this->setCsrfHeader();
$this->client->xmlHttpRequest(Request::METHOD_POST, '/s/ajax', $payload);
$this->assertResponseIsSuccessful();
$response = json_decode($this->client->getResponse()->getContent(), true);
// Ensure the contact 1 is a campaign 1 member now.
$this->assertSame(
[['lead_id' => (string) $contact->getId(), 'manually_added' => '1', 'manually_removed' => '0']],
$this->getMembersForCampaign($campaign->getId()),
$this->client->getResponse()->getContent()
);
$this->assertTrue(isset($response['success']), 'The response does not contain the `success` param.');
$this->assertSame(1, $response['success']);
// Let's remove the member now.
$payload = [
'action' => 'lead:toggleLeadCampaign',
'leadId' => $contact->getId(),
'campaignId' => $campaign->getId(),
'campaignAction' => 'remove',
];
$this->client->xmlHttpRequest(Request::METHOD_POST, '/s/ajax', $payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
// Ensure the contact 1 was removed as a member of campaign 1 member now.
$this->assertSame([['lead_id' => (string) $contact->getId(), 'manually_added' => '0', 'manually_removed' => '1']], $this->getMembersForCampaign($campaign->getId()));
$this->assertTrue($clientResponse->isOk(), $clientResponse->getContent());
$this->assertTrue(isset($response['success']), 'The response does not contain the `success` param.');
$this->assertSame(1, $response['success']);
}
public function testSegmentDependencyTreeWithNotExistingSegment(): void
{
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:getSegmentDependencyTree&id=9999');
$response = $this->client->getResponse();
Assert::assertSame(404, $response->getStatusCode());
Assert::assertSame('{"message":"Segment 9999 could not be found."}', $response->getContent());
}
public function testCompanyLookupWithNoCompanySelected(): void
{
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:getLookupChoiceList&searchKey=lead.company&lead.company=unicorn');
$response = $this->client->getResponse();
Assert::assertSame(200, $response->getStatusCode());
Assert::assertSame('[]', $response->getContent());
}
public function testCompanyLookupWithCompanySelected(): void
{
$company = new Company();
$company->setName('SaaS Company');
$this->em->persist($company);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:getLookupChoiceList&searchKey=lead.company&lead.company=sa');
$response = $this->client->getResponse();
Assert::assertSame(200, $response->getStatusCode());
Assert::assertSame('[{"text":"SaaS Company","value":"'.$company->getId().'"}]', $response->getContent());
}
public function testCompanyLookupWithNoModelSet(): void
{
$this->client->xmlHttpRequest(Request::METHOD_GET, '/s/ajax?action=lead:getLookupChoiceList&lead.company=unicorn');
$response = $this->client->getResponse();
Assert::assertSame(400, $response->getStatusCode());
Assert::assertStringContainsString('Bad Request - The searchKey parameter is required', $response->getContent());
}
public function testCompanyLookupWithLimit(): void
{
$company1 = new Company();
$company1->setName('Company 1');
$this->em->persist($company1);
$company2 = new Company();
$company2->setName('Company 2');
$this->em->persist($company2);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:getLookupChoiceList&searchKey=lead.company&lead.company=Company&limit=1');
$response = $this->client->getResponse();
$content = json_decode($response->getContent(), true);
Assert::assertSame(200, $response->getStatusCode());
Assert::assertIsArray($content);
Assert::assertCount(1, $content, 'The result should contain only one element');
Assert::assertSame('Company 1', $content[0]['text']);
Assert::assertSame($company1->getId(), (int) $content[0]['value']);
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:getLookupChoiceList&searchKey=lead.company&lead.company=Company&limit=1&start=1');
$response = $this->client->getResponse();
$content = json_decode($response->getContent(), true);
Assert::assertSame(200, $response->getStatusCode());
Assert::assertIsArray($content);
Assert::assertCount(1, $content, 'The result should contain only one element');
Assert::assertSame('Company 2', $content[0]['text']);
Assert::assertSame($company2->getId(), (int) $content[0]['value']);
}
public function testSegmentDependencyTree(): void
{
$segmentA = new LeadList();
$segmentA->setName('Segment A');
$segmentA->setPublicName('Segment A');
$segmentA->setAlias('segment-a');
$segmentB = new LeadList();
$segmentB->setName('Segment B');
$segmentB->setPublicName('Segment B');
$segmentB->setAlias('segment-b');
$segmentC = new LeadList();
$segmentC->setName('Segment C');
$segmentC->setPublicName('Segment C');
$segmentC->setAlias('segment-c');
$segmentD = new LeadList();
$segmentD->setName('Segment D');
$segmentD->setPublicName('Segment D');
$segmentD->setAlias('segment-d');
$segmentE = new LeadList();
$segmentE->setName('Segment E');
$segmentE->setPublicName('Segment E');
$segmentE->setAlias('segment-e');
$this->em->persist($segmentA);
$this->em->persist($segmentB);
$this->em->persist($segmentC);
$this->em->persist($segmentD);
$this->em->persist($segmentE);
$this->em->flush();
$segmentA->setFilters(
[
[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => ['filter' => [$segmentB->getId()]],
], [
'object' => 'lead',
'glue' => 'or',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => '!in',
'properties' => ['filter' => [$segmentC->getId(), $segmentD->getId()]],
],
]
);
$segmentC->setFilters(
[
[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => ['filter' => [$segmentE->getId()]],
],
]
);
$this->em->persist($segmentA);
$this->em->persist($segmentC);
$this->em->flush();
$this->client->request(Request::METHOD_GET, "/s/ajax?action=lead:getSegmentDependencyTree&id={$segmentA->getId()}");
$response = $this->client->getResponse();
self::assertTrue($response->isOk(), $response->getContent());
Assert::assertSame(
[
'levels' => [
[
'nodes' => [
['id' => "0-{$segmentA->getId()}", 'name' => $segmentA->getName(), 'link' => "/s/segments/view/{$segmentA->getId()}"],
],
],
[
'nodes' => [
['id' => "{$segmentA->getId()}-{$segmentB->getId()}", 'name' => $segmentB->getName(), 'link' => "/s/segments/view/{$segmentB->getId()}"],
['id' => "{$segmentA->getId()}-{$segmentC->getId()}", 'name' => $segmentC->getName(), 'link' => "/s/segments/view/{$segmentC->getId()}"],
['id' => "{$segmentA->getId()}-{$segmentD->getId()}", 'name' => $segmentD->getName(), 'link' => "/s/segments/view/{$segmentD->getId()}"],
],
],
[
'nodes' => [
['id' => "{$segmentC->getId()}-{$segmentE->getId()}", 'name' => $segmentE->getName(), 'link' => "/s/segments/view/{$segmentE->getId()}"],
],
],
],
'edges' => [
['source' => "0-{$segmentA->getId()}", 'target' => "{$segmentA->getId()}-{$segmentB->getId()}"],
['source' => "0-{$segmentA->getId()}", 'target' => "{$segmentA->getId()}-{$segmentC->getId()}"],
['source' => "0-{$segmentA->getId()}", 'target' => "{$segmentA->getId()}-{$segmentD->getId()}"],
['source' => "{$segmentA->getId()}-{$segmentC->getId()}", 'target' => "{$segmentC->getId()}-{$segmentE->getId()}"],
],
],
json_decode($response->getContent(), true)
);
}
public function testSegmentDependencyTreeWithLoop(): void
{
$segmentA = new LeadList();
$segmentA->setName('Segment A');
$segmentA->setPublicName('Segment A');
$segmentA->setAlias('segment-a');
$segmentB = new LeadList();
$segmentB->setName('Segment B');
$segmentB->setPublicName('Segment B');
$segmentB->setAlias('segment-b');
$segmentC = new LeadList();
$segmentC->setName('Segment C');
$segmentC->setPublicName('Segment C');
$segmentC->setAlias('segment-c');
$segmentD = new LeadList();
$segmentD->setName('Segment D');
$segmentD->setPublicName('Segment D');
$segmentD->setAlias('segment-d');
$segmentE = new LeadList();
$segmentE->setName('Segment E');
$segmentE->setPublicName('Segment E');
$segmentE->setAlias('segment-e');
$this->em->persist($segmentA);
$this->em->persist($segmentB);
$this->em->persist($segmentC);
$this->em->persist($segmentD);
$this->em->persist($segmentE);
$this->em->flush();
$segmentA->setFilters(
[
[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => ['filter' => [$segmentB->getId()]],
], [
'object' => 'lead',
'glue' => 'or',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => '!in',
'properties' => ['filter' => [$segmentC->getId(), $segmentD->getId()]],
],
]
);
$segmentC->setFilters(
[
[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => ['filter' => [$segmentE->getId()]],
],
]
);
$segmentE->setFilters(
[
[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => ['filter' => [$segmentA->getId()]],
],
]
);
$this->em->persist($segmentA);
$this->em->persist($segmentC);
$this->em->flush();
$this->client->request(Request::METHOD_GET, "/s/ajax?action=lead:getSegmentDependencyTree&id={$segmentA->getId()}");
$response = $this->client->getResponse();
self::assertTrue($response->isOk(), $response->getContent());
$responseData = json_decode($response->getContent(), true);
Assert::assertSame(
[
'levels' => [
[
'nodes' => [
['id' => "0-{$segmentA->getId()}", 'name' => $segmentA->getName(), 'link' => "/s/segments/view/{$segmentA->getId()}"],
],
],
[
'nodes' => [
['id' => "{$segmentA->getId()}-{$segmentB->getId()}", 'name' => $segmentB->getName(), 'link' => "/s/segments/view/{$segmentB->getId()}"],
['id' => "{$segmentA->getId()}-{$segmentC->getId()}", 'name' => $segmentC->getName(), 'link' => "/s/segments/view/{$segmentC->getId()}"],
['id' => "{$segmentA->getId()}-{$segmentD->getId()}", 'name' => $segmentD->getName(), 'link' => "/s/segments/view/{$segmentD->getId()}"],
],
],
[
'nodes' => [
['id' => "{$segmentC->getId()}-{$segmentE->getId()}", 'name' => $segmentE->getName(), 'link' => "/s/segments/view/{$segmentE->getId()}"],
],
],
[
'nodes' => [
[
'id' => "{$segmentE->getId()}-{$segmentA->getId()}",
'name' => $segmentA->getName(),
'link' => "/s/segments/view/{$segmentA->getId()}",
'message' => 'This segment already exists in the segment dependency tree',
],
],
],
],
],
[
'levels' => $responseData['levels'],
]
);
$expectedEdges = [
['source' => "0-{$segmentA->getId()}", 'target' => "{$segmentA->getId()}-{$segmentB->getId()}"],
['source' => "0-{$segmentA->getId()}", 'target' => "{$segmentA->getId()}-{$segmentC->getId()}"],
['source' => "0-{$segmentA->getId()}", 'target' => "{$segmentA->getId()}-{$segmentD->getId()}"],
['source' => "{$segmentA->getId()}-{$segmentC->getId()}", 'target' => "{$segmentC->getId()}-{$segmentE->getId()}"],
['source' => "{$segmentC->getId()}-{$segmentE->getId()}", 'target' => "{$segmentE->getId()}-{$segmentA->getId()}"],
];
$actualEdges = $responseData['edges'];
Assert::assertCount(count($expectedEdges), $actualEdges, 'Should have the correct number of edges');
foreach ($expectedEdges as $expectedEdge) {
$edgeFound = false;
foreach ($actualEdges as $actualEdge) {
if ($actualEdge['source'] === $expectedEdge['source'] && $actualEdge['target'] === $expectedEdge['target']) {
$edgeFound = true;
break;
}
}
Assert::assertTrue($edgeFound, "Expected edge {$expectedEdge['source']} -> {$expectedEdge['target']} not found");
}
}
public function testRemoveTagFromLeadAction(): void
{
// Create a lead
$lead = $this->createContact('test@email.com');
// ... set other properties as needed
// Create a tag
$tag = new Tag();
$tag->setTag('Test Tag');
// ... set other properties as needed
// Link the lead and tag
$lead->addTag($tag);
// Persist the lead and tag
$this->em->persist($lead);
$this->em->persist($tag);
$this->em->flush();
// Call the removeTagFromLeadAction
$this->client->request(Request::METHOD_POST, '/s/ajax?action=lead:removeTagFromLead', [
'leadId' => $lead->getId(),
'tagId' => $tag->getId(),
]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertTrue($clientResponse->isOk(), $clientResponse->getContent());
// Assert the tag is removed from the lead
$updatedLead = $this->em->getRepository(Lead::class)->find($lead->getId());
$this->assertFalse(in_array($tag, $updatedLead->getTags()->toArray()));
}
public function testContactListActionSuggestionsByAdminUser(): void
{
/** @var UserRepository $userRepository */
$userRepository = $this->em->getRepository(User::class);
/** @var User $adminUser */
$adminUser = $userRepository->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $adminUser);
$salesUser = $userRepository->findOneBy(['username' => 'sales']);
self::assertInstanceOf(User::class, $salesUser);
$leads = [];
// Create 4 leads with two owned by admin and sales users respectively.
for ($i = 1; $i <= 4; ++$i) {
$owner = $adminUser;
if ($i > 2) {
$owner = $salesUser;
}
$lead = new Lead();
$lead->setFirstname("User $i");
$lead->setOwner($owner);
$leads[] = $lead;
}
/** @var LeadRepository $leadRepository */
$leadRepository = $this->em->getRepository(Lead::class);
$leadRepository->saveEntities($leads);
$this->em->clear();
// Check suggestions for admin user.
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:contactList&field=undefined&filter=user');
$response = $this->client->getResponse();
self::assertTrue($response->isOk());
$data = json_decode($response->getContent(), true);
$foundNames = array_column($data, 'value');
self::assertCount(4, $foundNames);
foreach ($foundNames as $key => $name) {
self::assertSame('User '.($key + 1), $name);
}
}
public function testContactListActionSuggestionsByNonAdminUser(): void
{
/** @var UserRepository $userRepository */
$userRepository = $this->em->getRepository(User::class);
$adminUser = $userRepository->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $adminUser);
$leads = [];
// Create 2 leads with owned by admin user.
for ($i = 1; $i <= 2; ++$i) {
$lead = new Lead();
$lead->setFirstname("User $i");
$lead->setOwner($adminUser);
$leads[] = $lead;
}
/** @var LeadRepository $leadRepository */
$leadRepository = $this->em->getRepository(Lead::class);
$leadRepository->saveEntities($leads);
$this->em->clear();
$role = new Role();
$role->setName('Role');
$role->setIsAdmin(false);
$role->setRawPermissions(['lead:leads' => ['viewown']]);
/** @var RoleRepository $roleRepository */
$roleRepository = $this->em->getRepository(Role::class);
$roleRepository->saveEntity($role);
// Create a non admin user with view own contacts permission.
$user = new User();
$user->setFirstName('Non');
$user->setLastName('Admin');
$user->setEmail('non-admin-user@test.com');
$user->setUsername('non-admin-user');
$user->setRole($role);
/** @var PasswordHasherInterface $hasher */
$hasher = static::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
$passwordNonAdmin = 'Maut1cR0cks!';
$user->setPassword($hasher->hash($passwordNonAdmin));
$userRepository->saveEntity($user);
/** @var User $nonAdminUser */
$nonAdminUser = $userRepository->findOneBy(['email' => 'non-admin-user@test.com']);
$nonAdminLeads = [];
// Create 2 leads with owned by non-admin user.
for ($i = 3; $i <= 4; ++$i) {
$lead = new Lead();
$lead->setFirstname("User $i");
$lead->setOwner($nonAdminUser);
$nonAdminLeads[] = $lead;
}
$leadRepository->saveEntities($nonAdminLeads);
$this->em->clear();
$this->logoutUser();
// Check suggestions for a non admin user.
$this->client->loginUser($nonAdminUser, 'mautic');
$this->client->setServerParameter('PHP_AUTH_USER', 'non-admin-user');
// Set the new password, because new authenticator system checks for it.
$this->client->setServerParameter('PHP_AUTH_PW', $passwordNonAdmin);
$this->client->request(Request::METHOD_GET, '/s/ajax?action=lead:contactList&field=undefined&filter=user');
$response = $this->client->getResponse();
self::assertTrue($response->isOk());
$data = json_decode($response->getContent(), true);
$foundNames = array_column($data, 'value');
self::assertCount(2, $foundNames);
self::assertSame('User 3', $foundNames[0]);
self::assertSame('User 4', $foundNames[1]);
}
/**
* @param string[] $expectedOptions
*/
#[\PHPUnit\Framework\Attributes\DataProvider('leadFieldOrderChoiceListProvider')]
public function testUpdateLeadFieldOrderChoiceListAction(string $object, string $group, array $expectedOptions): void
{
$payload = [
'action' => 'lead:updateLeadFieldOrderChoiceList',
'object' => $object,
'group' => $group,
];
$this->setCsrfHeader();
$this->client->xmlHttpRequest(Request::METHOD_POST, '/s/ajax', $payload);
// Get the response HTML
$response = $this->client->getResponse();
$htmlContent = $response->getContent();
// Assert the response is successful
$this->assertTrue($response->isOk(), "Response was not OK for object: $object, group: $group");
$this->assertStringNotContainsString('<form', $htmlContent, 'Response contains a form instead of just field order.');
$this->assertStringContainsString('<select', $htmlContent, 'Response contains select tag.');
// Parse the HTML content using DOMDocument
$dom = new \DOMDocument();
@$dom->loadHTML($htmlContent);
$select = $dom->getElementsByTagName('select')->item(0);
$options = $select->getElementsByTagName('option');
$actualOptions = [];
foreach ($options as $option) {
if ($option->textContent) {
// Get the text content of each <option>
$actualOptions[] = trim($option->textContent);
}
}
// Assert that the actual options match the expected options
if (empty($expectedOptions)) {
$this->assertEmpty($actualOptions);
}
foreach ($expectedOptions as $expectedValue) {
$this->assertContains($expectedValue, $actualOptions, "Missing expected option '$expectedValue' for object: $object, group: $group");
}
}
public static function leadFieldOrderChoiceListProvider(): \Generator
{
yield ['lead', 'core', ['Fax', 'Website']];
yield ['lead', 'social', ['Facebook', 'Foursquare', 'Instagram']];
yield ['company', 'core', []];
}
public function testTogglePreferredLeadChannelActionWithFlashMessage(): void
{
$contact = $this->createContact('test@example.com');
$payload = [
'action' => 'lead:togglePreferredLeadChannel',
'leadId' => $contact->getId(),
'channel' => 'email',
'channelAction' => 'remove',
];
$this->setCsrfHeader();
$this->client->xmlHttpRequest(Request::METHOD_POST, '/s/ajax', $payload);
$this->assertResponseIsSuccessful();
$payload = [
'action' => 'lead:togglePreferredLeadChannel',
'leadId' => $contact->getId(),
'channel' => 'email',
'channelAction' => 'add',
];
$this->client->xmlHttpRequest(Request::METHOD_POST, '/s/ajax', $payload);
$this->assertResponseIsSuccessful();
$response = json_decode($this->client->getResponse()->getContent(), true);
$this->assertTrue(isset($response['success']), 'The response does not contain the `success` param.');
$this->assertSame(1, $response['success']);
$this->assertTrue(isset($response['flashes']), 'The response should contain flashes');
$this->assertNotEmpty($response['flashes'], 'The flashes should not be empty');
$this->assertStringContainsString('Contact is now contactable on the email channel', $response['flashes'], 'Flash message about channel being contactable should be present');
}
public function testRemoveBounceStatusActionWithFlashMessage(): void
{
$contact = $this->createContact('bounce@example.com');
$dnc = new DoNotContact();
$dnc->setLead($contact);
$dnc->setChannel('email');
$dnc->setReason(DoNotContact::BOUNCED);
$dnc->setDateAdded(new \DateTime());
$this->em->persist($dnc);
$this->em->flush();
$payload = [
'action' => 'lead:removeBounceStatus',
'id' => $dnc->getId(),
'channel' => 'email',
];
$this->setCsrfHeader();
$this->client->xmlHttpRequest(Request::METHOD_POST, '/s/ajax', $payload);
$this->assertResponseIsSuccessful();
$response = json_decode($this->client->getResponse()->getContent(), true);
$this->assertTrue(isset($response['success']), 'The response does not contain the `success` param.');
$this->assertSame(1, $response['success']);
$this->assertTrue(isset($response['flashes']), 'The response should contain flashes');
$this->assertNotEmpty($response['flashes'], 'The flashes should not be empty');
$this->assertStringContainsString('Contact is now contactable on the email channel', $response['flashes'], 'Flash message about channel being contactable should be present');
}
public function testFieldListAction(): void
{
// Check if alias is null
$this->client->request(
Request::METHOD_GET,
'/s/ajax',
[
'action' => 'lead:fieldList',
'field' => '',
'filter' => '',
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertSame('Alias cannot be empty', $response['error']);
// User search for filter
$this->client->request(
Request::METHOD_GET,
'/s/ajax',
[
'action' => 'lead:fieldList',
'field' => 'owner_id',
'filter' => 'Admin',
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertSame('Admin User', $response[0]['value']);
// Check if filed type is not lookup
$this->client->request(
Request::METHOD_GET,
'/s/ajax',
[
'action' => 'lead:fieldList',
'field' => 'city',
'filter' => '',
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertEmpty($response);
// Check if filed type is lookup
$this->client->request(
Request::METHOD_GET,
'/s/ajax',
[
'action' => 'lead:fieldList',
'field' => 'title',
'filter' => '',
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertArrayHasKey(0, $response);
}
public function testGetLookupChoiceListForGlobalCategory(): void
{
$category1 = $this->createGlobalCategory('GC11');
$category2 = $this->createGlobalCategory('GC12');
// Search global category with string filter
$this->client->request(
Request::METHOD_GET,
'/s/ajax',
[
'action' => 'lead:getLookupChoiceList',
'for_lookup' => 1,
'searchKey' => 'category.category',
'category_category' => 'GC11',
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertSame($category1->getTitle(), $response[0]['text']);
// Search global category with array of ids
$this->client->request(
Request::METHOD_GET,
'/s/ajax',
[
'action' => 'lead:getLookupChoiceList',
'for_lookup' => 1,
'searchKey' => 'category.category',
'category_category' => [$category1->getId(), $category2->getId()],
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertCount(2, $response);
}
public function testLoadSegmentFilterFormForSubscribedCategory(): void
{
$this->client->request(
Request::METHOD_POST,
'/s/ajax',
[
'action' => 'lead:loadSegmentFilterForm',
'fieldAlias' => 'globalcategory',
'fieldObject' => 'lead',
'operator' => 'in',
'filterNum' => 2,
]
);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertArrayHasKey('viewParameters', $response);
Assert::assertStringContainsString('data-model="category.category"', $response['viewParameters']['form']);
}
private function getMembersForCampaign(int $campaignId): array
{
return $this->connection->createQueryBuilder()
->select('cl.lead_id, cl.manually_added, cl.manually_removed')
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
->where("cl.campaign_id = {$campaignId}")
->executeQuery()
->fetchAllAssociative();
}
private function createContact(string $email): Lead
{
$lead = new Lead();
$lead->setEmail($email);
$this->em->persist($lead);
$this->em->flush();
return $lead;
}
private function createCampaign(): Campaign
{
$campaign = new Campaign();
$campaign->setName('Campaign A');
$campaign->setCanvasSettings(
[
'nodes' => [
[
'id' => '148',
'positionX' => '760',
'positionY' => '155',
],
[
'id' => 'lists',
'positionX' => '860',
'positionY' => '50',
],
],
'connections' => [
[
'sourceId' => 'lists',
'targetId' => '148',
'anchors' => [
'source' => 'leadsource',
'target' => 'top',
],
],
],
]
);
$this->em->persist($campaign);
$this->em->flush();
return $campaign;
}
private function createGlobalCategory(string $title): Category
{
$category = new Category();
$category->setIsPublished(true)
->setTitle($title)
->setAlias(strtolower($title))
->setBundle('global');
$this->em->persist($category);
$this->em->flush();
return $category;
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadField;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Response;
class CompanyApiControllerFunctionalTest extends MauticMysqlTestCase
{
/**
* @throws \Doctrine\ORM\Exception\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function markCompanyEmailAsUnique(): void
{
$fieldRepository = $this->em->getRepository(LeadField::class);
$companyEmailField = $fieldRepository->findOneBy(['alias' => 'companyemail']);
\assert($companyEmailField instanceof LeadField);
$companyEmailField->setIsUniqueIdentifer(true);
$this->em->persist($companyEmailField);
$this->em->flush();
}
protected function setUp(): void
{
// Disable API just for specific test.
$this->configParams['api_enabled'] = 'testDisabledApi' !== $this->name();
$this->configParams['company_unique_identifiers_operator'] = 'AND';
parent::setUp();
}
public function testBatchNewEndpoint(): void
{
$this->markCompanyEmailAsUnique();
$payload = [
[
'companyname' => 'BatchUpdate',
],
[
'companyname' => 'BatchUpdate2',
],
[
'companyname' => 'BatchUpdate3',
],
];
// create 3 new companies
$this->client->request('POST', '/api/companies/batch/new', $payload);
$clientResponse = $this->client->getResponse();
Assert::assertSame(Response::HTTP_CREATED, $clientResponse->getStatusCode(), $clientResponse->getContent());
$response = json_decode($clientResponse->getContent(), true);
// Assert status codes
$this->assertEquals(Response::HTTP_CREATED, $response['statusCodes'][0]);
$companyId1 = $response['companies'][0]['id'];
$this->assertEquals(Response::HTTP_CREATED, $response['statusCodes'][1]);
$this->assertEquals(Response::HTTP_CREATED, $response['statusCodes'][2]);
// Assert email
$this->assertEquals($payload[0]['companyname'], $response['companies'][0]['fields']['all']['companyname']);
$this->assertEquals($payload[1]['companyname'], $response['companies'][1]['fields']['all']['companyname']);
$this->assertEquals($payload[2]['companyname'], $response['companies'][2]['fields']['all']['companyname']);
$payload = [
[
'companyname' => 'BatchUpdate',
],
];
// use unique field to not create new company
$this->client->request('POST', '/api/companies/batch/new', $payload);
$clientResponse = $this->client->getResponse();
Assert::assertSame(Response::HTTP_CREATED, $clientResponse->getStatusCode(), $clientResponse->getContent());
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals(Response::HTTP_OK, $response['statusCodes'][0]);
$this->assertEquals($companyId1, $response['companies'][0]['id']);
// Assert email
$this->assertEquals('BatchUpdate', $response['companies'][0]['fields']['all']['companyname']);
$payload = [
[
'companyname' => 'BatchUpdate',
'companyemail' => 'BatchUpdate@update.com',
],
];
// use both unique fields and create new, because use AND operator between unique fields
$this->client->request('POST', '/api/companies/batch/new', $payload);
$clientResponse = $this->client->getResponse();
Assert::assertSame(Response::HTTP_CREATED, $clientResponse->getStatusCode(), $clientResponse->getContent());
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals(Response::HTTP_CREATED, $response['statusCodes'][0]);
$this->assertNotEquals($companyId1, $response['companies'][0]['id']);
}
public function testSingleNewEndpoint(): void
{
$this->markCompanyEmailAsUnique();
$payload = [
'companyname' => 'API',
];
$this->client->request('POST', '/api/companies/new', $payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$companyId = $response['company']['id'];
$this->assertEquals($payload['companyname'], $response['company']['fields']['all']['companyname']);
// Lets try to create the same company
$this->client->request('POST', '/api/companies/new', $payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals($companyId, $response['company']['id']);
$payload = [
'companyname' => 'API',
'companyemail' => 'api@api.com',
];
// Lets try to create the new company because use unique fields with AND operator
$this->client->request('POST', '/api/companies/new', $payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertNotEquals($companyId, $response['company']['id']);
}
/**
* Test creating a company via API Platform v2 endpoint.
*
* @param array<string, mixed> $companyData
*/
#[\PHPUnit\Framework\Attributes\DataProvider('companyCreateDataProvider')]
public function testCreateCompanyViaApiPlatform(array $companyData, int $expectedStatusCode): void
{
$this->client->request(
'POST',
'/api/v2/companies',
[],
[],
[
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
json_encode($companyData)
);
$response = $this->client->getResponse();
$this->assertSame($expectedStatusCode, $response->getStatusCode(), $response->getContent());
if (Response::HTTP_CREATED === $expectedStatusCode) {
$responseData = json_decode($response->getContent(), true);
$this->assertIsArray($responseData);
$this->assertArrayHasKey('id', $responseData);
$this->assertArrayHasKey('score', $responseData);
// Verify the company was actually created in the database
$companyRepository = $this->em->getRepository(\Mautic\LeadBundle\Entity\Company::class);
$company = $companyRepository->find($responseData['id']);
$this->assertInstanceOf(\Mautic\LeadBundle\Entity\Company::class, $company);
$this->assertSame($companyData['name'] ?? null, $company->getName());
$this->assertSame($companyData['score'] ?? 0, $company->getScore());
$this->assertSame($companyData['city'] ?? null, $company->getCity());
$this->assertSame($companyData['state'] ?? null, $company->getState());
$this->assertSame($companyData['country'] ?? null, $company->getCountry());
$this->assertSame($companyData['industry'] ?? null, $company->getIndustry());
}
}
/**
* @return array<string, array{companyData: array<string, mixed>, expectedStatusCode: int}>
*/
public static function companyCreateDataProvider(): array
{
return [
'valid company with all fields' => [
'companyData' => [
'score' => 0,
'socialCache' => [],
'city' => 'Boston',
'state' => 'Massachusetts',
'country' => 'United States',
'name' => 'Mautic',
'industry' => 'Software',
],
'expectedStatusCode' => Response::HTTP_CREATED,
],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\LeadBundle\Controller\Api\CustomFieldsApiControllerTrait;
use Mautic\LeadBundle\Model\FieldModel;
use PHPUnit\Framework\Assert;
final class CustomFieldsApiControllerTraitTest extends \PHPUnit\Framework\TestCase
{
public function testGetEntityFormOptions(): void
{
$result = [
'field_1' => [
'label' => 'Field 1',
'type' => 'text',
],
'field_2' => [
'label' => 'Field 2',
'type' => 'text',
],
];
$paginator = $this->createMock(Paginator::class);
$paginator->method('getIterator')
->willReturn($result);
$modelFake = $this->createMock(FieldModel::class);
$modelFake->expects(self::once())
->method('getEntities')
->willReturn($paginator);
$controller = new class($modelFake) {
use CustomFieldsApiControllerTrait;
private object $model;
private string $entityNameOne = 'lead';
public function __construct(object $modelFake)
{
$this->model = $modelFake;
}
/**
* @return mixed[]
*/
public function getEntityFormOptionsPublic(): array
{
return $this->getEntityFormOptions();
}
public function getModel(?string $name): object
{
return $this->model;
}
};
Assert::assertSame($result, (array) $controller->getEntityFormOptionsPublic()['fields']); // Calling once, should be live
Assert::assertSame($result, (array) $controller->getEntityFormOptionsPublic()['fields']); // Calling twice, should be cached
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class DeviceApiControllerFunctionalTest extends MauticMysqlTestCase
{
public function testPutEditWithInexistingIdSoItShouldCreate(): void
{
$contact = new Lead();
$this->em->persist($contact);
$this->em->flush();
$this->client->request(Request::METHOD_PUT, '/api/devices/99999/edit', [
'device' => 'desktop',
'deviceOsName' => 'Ubuntu',
'deviceOsShortName' => 'UBT',
'deviceOsPlatform' => 'x64',
'lead' => $contact->getId(),
]);
self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
}
}

View File

@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Doctrine\Common\Annotations\Annotation\IgnoreAnnotation;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @IgnoreAnnotation("covers")
*/
#[\PHPUnit\Framework\Attributes\CoversClass(\Mautic\LeadBundle\Controller\Api\FieldApiController::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(\Mautic\LeadBundle\Field\Command\CreateCustomFieldCommand::class)]
final class FieldApiControllerFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
protected function setUp(): void
{
$this->configParams['create_custom_field_in_background'] = 'testFieldApiEndpointsWithBackgroundProcessingEnabled' === $this->name();
parent::setUp();
}
public function testCreatingMultiselectField(): void
{
$payload = [
'label' => 'Shops (TB)',
'alias' => 'shops',
'type' => 'multiselect',
'isPubliclyUpdatable' => true,
'isUniqueIdentifier' => false,
'properties' => [
'list' => [
['label' => 'label1', 'value' => 'value1'],
['label' => 'label2', 'value' => 'value2'],
],
],
];
$typeSafePayload = $this->generateTypeSafePayload($payload);
$this->client->request(Request::METHOD_POST, '/api/fields/contact/new', $typeSafePayload);
$clientResponse = $this->client->getResponse();
$fieldResponse = json_decode($clientResponse->getContent(), true);
self::assertResponseStatusCodeSame(Response::HTTP_CREATED, $clientResponse->getContent());
Assert::assertTrue($fieldResponse['field']['isPublished']);
Assert::assertGreaterThan(0, $fieldResponse['field']['id']);
Assert::assertSame($payload['label'], $fieldResponse['field']['label']);
Assert::assertSame($payload['alias'], $fieldResponse['field']['alias']);
Assert::assertSame($payload['type'], $fieldResponse['field']['type']);
Assert::assertSame($payload['isPubliclyUpdatable'], $fieldResponse['field']['isPubliclyUpdatable']);
Assert::assertSame($payload['isUniqueIdentifier'], $fieldResponse['field']['isUniqueIdentifier']);
Assert::assertSame($payload['properties'], $fieldResponse['field']['properties']);
// Cleanup
$this->client->request(Request::METHOD_DELETE, '/api/fields/contact/'.$fieldResponse['field']['id'].'/delete', $payload);
$clientResponse = $this->client->getResponse();
self::assertResponseIsSuccessful($clientResponse->getContent());
}
public function testFieldApiEndpointsWithBackgroundProcessingEnabled(): void
{
$alias = uniqid('field');
$payload = $this->getCreatePayload($alias);
$id = $this->assertCreateResponse($payload, Response::HTTP_ACCEPTED);
// Test that the command will create the field
$commandTester = $this->testSymfonyCommand('mautic:custom-field:create-column', ['--id' => $id]);
$this->assertEquals(0, $commandTester->getStatusCode());
// Test fetching
$this->assertGetResponse($payload, $id);
// Test editing
$payload = $this->getEditPayload($id);
$this->assertPatchResponse($payload, $id, $alias);
// Test deleting
$this->assertDeleteResponse($payload, $id, $alias, true);
}
public function testFieldApiEndpointsWithBackgroundProcessingDisabled(): void
{
$alias = uniqid('field');
$payload = $this->getCreatePayload($alias);
$id = $this->assertCreateResponse($payload, Response::HTTP_CREATED);
// Test fetching
$this->assertGetResponse($payload, $id);
// Test editing
$payload = $this->getEditPayload($id);
$this->assertPatchResponse($payload, $id, $alias);
// Test deleting
$this->assertDeleteResponse($payload, $id, $alias, false);
}
/**
* @param array<string, array<string, string>> $properties
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataForCreatingNewBooleanFieldApiEndpoint')]
public function testCreatingNewBooleanFieldApiEndpoint(array $properties, string $expectedMessage): void
{
$payload = [
'label' => 'Request a meeting',
'alias' => 'meeting',
'type' => 'boolean',
'isPubliclyUpdatable' => true,
'isUniqueIdentifier' => false,
];
$payload += $properties;
$typeSafePayload = $this->generateTypeSafePayload($payload);
$this->client->request(Request::METHOD_POST, '/api/fields/contact/new', $typeSafePayload);
$clientResponse = $this->client->getResponse();
$errorResponse = json_decode($clientResponse->getContent(), true);
Assert::assertArrayHasKey('errors', $errorResponse);
Assert::assertSame($errorResponse['errors'][0]['code'], $clientResponse->getStatusCode());
Assert::assertSame($expectedMessage, $errorResponse['errors'][0]['message']);
}
/**
* @return iterable<string, array<int, string|array<string, array<string, string>>>>
*/
public static function dataForCreatingNewBooleanFieldApiEndpoint(): iterable
{
yield 'No properties' => [
[
],
'A \'positive\' label is required.',
];
yield 'Only Yes' => [
[
'properties'=> [
'yes' => 'Yes',
],
],
'A \'negative\' label is required.',
];
yield 'Only No' => [
[
'properties'=> [
'no' => 'No',
],
],
'A \'positive\' label is required.',
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('provideEmptyMultiSelectValue')]
public function testMultiselectSetDefaultValue(mixed $defaultFieldValue): void
{
$fieldAlias = 'test_multi';
$fieldModel = $this->getContainer()->get(FieldModel::class);
\assert($fieldModel instanceof FieldModel);
$fields = $fieldModel->getLeadFieldCustomFields();
Assert::assertEmpty($fields, 'There are no Custom Fields.');
// Add field.
$leadField = new LeadField();
$leadField->setName('Test Field')
->setAlias($fieldAlias)
->setType('multiselect')
->setObject('lead')
->setProperties([
'list' => [
[
'label' => 'Halusky',
'value' => 'halusky',
],
[
'label' => 'Bramborak',
'value' => 'bramborak',
],
[
'label' => 'Makovec',
'value' => 'makovec',
],
],
]);
$fieldModel->saveEntity($leadField);
$this->em->flush();
$contact = new Lead();
$contact->setEmail('email@acquia.cz');
$contact->addUpdatedField($fieldAlias, ['bramborak', 'makovec']);
$contactModel = self::getContainer()->get(LeadModel::class);
\assert($contactModel instanceof LeadModel);
$contactModel->saveEntity($contact);
$this->em->flush();
$this->em->clear();
// Call endpoint
$this->client->request('GET', '/api/contacts/'.(string) $contact->getId());
$clientResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode());
$responseJson = \json_decode($clientResponse->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('contact', $responseJson);
self::assertArrayHasKey('fields', $responseJson['contact']);
self::assertArrayHasKey('core', $responseJson['contact']['fields']);
self::assertArrayHasKey($fieldAlias, $responseJson['contact']['fields']['core']);
self::assertArrayHasKey('value', $responseJson['contact']['fields']['core'][$fieldAlias]);
self::assertSame('bramborak|makovec', $responseJson['contact']['fields']['core'][$fieldAlias]['value']);
// Test patch and values should be updated
$updatedValues = [
$fieldAlias => ['halusky'],
];
$this->client->request(
'PATCH',
sprintf('/api/contacts/%d/edit', $contact->getId()),
$updatedValues
);
$clientResponse = $this->client->getResponse();
$responseJson = \json_decode($clientResponse->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('contact', $responseJson);
self::assertArrayHasKey('fields', $responseJson['contact']);
self::assertArrayHasKey('core', $responseJson['contact']['fields']);
self::assertArrayHasKey($fieldAlias, $responseJson['contact']['fields']['core']);
self::assertArrayHasKey('value', $responseJson['contact']['fields']['core'][$fieldAlias]);
self::assertSame('halusky', $responseJson['contact']['fields']['core'][$fieldAlias]['value']);
// Test empty patch and values should be updated
$updatedValues = [
$fieldAlias => $defaultFieldValue,
'overwriteWithBlank' => true,
];
$this->client->request(
'PATCH',
sprintf('/api/contacts/%d/edit', $contact->getId()),
$updatedValues
);
$clientResponse = $this->client->getResponse();
$responseJson = \json_decode($clientResponse->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('contact', $responseJson);
self::assertArrayHasKey('fields', $responseJson['contact']);
self::assertArrayHasKey('core', $responseJson['contact']['fields']);
self::assertArrayHasKey($fieldAlias, $responseJson['contact']['fields']['core']);
self::assertArrayHasKey('value', $responseJson['contact']['fields']['core'][$fieldAlias]);
self::assertSame('', $responseJson['contact']['fields']['core'][$fieldAlias]['value']);
}
/**
* @return \Iterator<string, array<string|array<string|null>|null>>
*/
public static function provideEmptyMultiSelectValue(): \Iterator
{
yield 'null' => [null];
yield 'empty string value' => [''];
yield 'empty array with empty string value' => [['']];
yield 'empty array with null value' => [[null]];
}
#[\PHPUnit\Framework\Attributes\DataProvider('provideEmptySelectValue')]
public function testSelectSetDefaultValue(mixed $defaultFieldValue): void
{
$fieldAlias = 'test_single';
$fieldModel = $this->getContainer()->get(FieldModel::class);
\assert($fieldModel instanceof FieldModel);
$fields = $fieldModel->getLeadFieldCustomFields();
Assert::assertEmpty($fields, 'There are no Custom Fields.');
// Add field.
$leadField = new LeadField();
$leadField->setName('Test Field')
->setAlias($fieldAlias)
->setType('select')
->setObject('lead')
->setProperties([
'list' => [
[
'label' => 'Halusky',
'value' => 'halusky',
],
[
'label' => 'Bramborak',
'value' => 'bramborak',
],
[
'label' => 'Makovec',
'value' => 'makovec',
],
],
]);
$fieldModel->saveEntity($leadField);
$this->em->flush();
$contact = new Lead();
$contact->setEmail('email@acquia.cz');
$contact->addUpdatedField($fieldAlias, ['makovec']);
$contactModel = self::getContainer()->get(LeadModel::class);
\assert($contactModel instanceof LeadModel);
$contactModel->saveEntity($contact);
$this->em->flush();
$this->em->clear();
// Call endpoint
$this->client->request('GET', '/api/contacts/'.(string) $contact->getId());
$clientResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode());
$responseJson = \json_decode($clientResponse->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('contact', $responseJson);
self::assertArrayHasKey('fields', $responseJson['contact']);
self::assertArrayHasKey('core', $responseJson['contact']['fields']);
self::assertArrayHasKey($fieldAlias, $responseJson['contact']['fields']['core']);
self::assertArrayHasKey('value', $responseJson['contact']['fields']['core'][$fieldAlias]);
self::assertSame('makovec', $responseJson['contact']['fields']['core'][$fieldAlias]['value']);
// Test patch and values should be updated
$updatedValues = [
$fieldAlias => 'halusky',
];
$this->client->request(
'PATCH',
sprintf('/api/contacts/%d/edit', $contact->getId()),
$updatedValues
);
$clientResponse = $this->client->getResponse();
$responseJson = \json_decode($clientResponse->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('contact', $responseJson);
self::assertArrayHasKey('fields', $responseJson['contact']);
self::assertArrayHasKey('core', $responseJson['contact']['fields']);
self::assertArrayHasKey($fieldAlias, $responseJson['contact']['fields']['core']);
self::assertArrayHasKey('value', $responseJson['contact']['fields']['core'][$fieldAlias]);
self::assertSame('halusky', $responseJson['contact']['fields']['core'][$fieldAlias]['value']);
// Test empty patch and values should be updated
$updatedValues = [
$fieldAlias => $defaultFieldValue,
'overwriteWithBlank' => true,
];
$this->client->request(
'PATCH',
sprintf('/api/contacts/%d/edit', $contact->getId()),
$updatedValues
);
$clientResponse = $this->client->getResponse();
$responseJson = \json_decode($clientResponse->getContent(), true, 512, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('contact', $responseJson);
self::assertArrayHasKey('fields', $responseJson['contact']);
self::assertArrayHasKey('core', $responseJson['contact']['fields']);
self::assertArrayHasKey($fieldAlias, $responseJson['contact']['fields']['core']);
self::assertArrayHasKey('value', $responseJson['contact']['fields']['core'][$fieldAlias]);
self::assertSame('', $responseJson['contact']['fields']['core'][$fieldAlias]['value']);
}
/**
* @return \Iterator<string, array<string|null>>
*/
public static function provideEmptySelectValue(): \Iterator
{
yield 'null' => [null];
yield 'empty string value' => [''];
}
private function assertCreateResponse(array $payload, int $expectedStatusCode): int
{
// Test creating a new field
$typeSafePayload = $this->generateTypeSafePayload($payload);
$this->client->request('POST', '/api/fields/contact/new', $typeSafePayload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
// should be "accepted" if pushed to the background; otherwise "created"
$this->assertEquals($expectedStatusCode, $clientResponse->getStatusCode());
// Assert that the fields returned are what is expected
foreach ($payload as $key => $value) {
$this->assertTrue(isset($response['field'][$key]));
if (Response::HTTP_ACCEPTED === $expectedStatusCode && 'isPublished' === $key) {
// This should be false because the background job publishes once ready
$this->assertEquals(false, $response['field'][$key]);
continue;
}
$this->assertEquals($value, $response['field'][$key]);
}
return $response['field']['id'];
}
private function assertGetResponse(array $payload, int $id): void
{
// Test get and that the field was published
$this->client->request('GET', sprintf('/api/fields/contact/%s', $id));
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
// Assert that the fields returned are what is expected and the field is now published
foreach ($payload as $key => $value) {
$this->assertTrue(isset($response['field'][$key]));
$this->assertEquals($value, $response['field'][$key]);
}
}
private function assertPatchResponse(array $payload, int $id, string $alias): void
{
$typeSafePayload = $this->generateTypeSafePayload($payload);
$this->client->request('PATCH', sprintf('/api/fields/contact/%s/edit', $id), $typeSafePayload);
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$response = json_decode($clientResponse->getContent(), true);
// Assert that the fields returned are what is expected noting that certain fields should not be editable
foreach ($payload as $key => $value) {
$this->assertTrue(isset($response['field'][$key]));
match ($key) {
'alias' => $this->assertEquals($alias, $response['field'][$key]),
'object' => $this->assertEquals('lead', $response['field'][$key]),
'type' => $this->assertEquals('text', $response['field'][$key]),
default => $this->assertEquals($value, $response['field'][$key]),
};
}
}
private function assertDeleteResponse(array $payload, int $id, string $alias, bool $isBackground): void
{
// Test the field is deleted
$this->client->request('DELETE', sprintf('/api/fields/contact/%s/delete', $id));
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$response = json_decode($clientResponse->getContent(), true);
// Assert that the fields returned are what is expected
foreach ($payload as $key => $value) {
// use array has key because ID will now be null
$this->assertArrayHasKey($key, $response['field']);
match ($key) {
'id' => $isBackground ? $this->assertEquals($value, $response['field'][$key]) : $this->assertNull($response['field'][$key]),
'alias' => $this->assertEquals($alias, $response['field'][$key]),
'object' => $this->assertEquals('lead', $response['field'][$key]),
'type' => $this->assertEquals('text', $response['field'][$key]),
default => $this->assertEquals($value, $response['field'][$key]),
};
}
}
private function getCreatePayload(string $alias): array
{
return [
'isPublished' => true,
'label' => 'New Field',
'alias' => $alias,
'type' => 'text',
'group' => 'core',
'object' => 'lead',
'defaultValue' => 'foobar',
'isRequired' => true,
'isPubliclyUpdatable' => true,
'isUniqueIdentifier' => true,
'isVisible' => false,
'isListable' => false,
'isIndex' => true, // Must be true, because if isUniqueIdentifier field is true the contact field *must* be indexed.
'charLengthLimit' => 25,
'properties' => [],
];
}
private function getEditPayload(int $id): array
{
return [
'id' => $id,
'label' => 'Foo Bar',
'alias' => 'should_not_change',
'type' => 'text',
'group' => 'core',
'object' => 'company',
'defaultValue' => 'foobar',
'isRequired' => false,
'isPubliclyUpdatable' => false,
'isUniqueIdentifier' => false,
'isVisible' => true,
'isListable' => true,
'isIndex' => false, // Can be false, if isUniqueIdentifier the field is *not* indexed.
'charLengthLimit' => 50,
'properties' => [],
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Controller\Api\FieldApiController;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Model\FieldModel;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class FieldApiControllerTest extends TestCase
{
private $defaultWhere = [
[
'col' => 'object',
'expr' => 'eq',
'val' => null,
],
];
public function testgetWhereFromRequestWithNoWhere(): void
{
$request = new Request();
$result = $this->getResultFromProtectedMethod('getWhereFromRequest', [$request], $request);
$this->assertEquals($this->defaultWhere, $result);
}
public function testgetWhereFromRequestWithSomeWhere(): void
{
$where = [
[
'col' => 'id',
'expr' => 'eq',
'val' => 5,
],
];
$request = new Request(['where' => $where]);
$result = $this->getResultFromProtectedMethod('getWhereFromRequest', [$request], $request);
$this->assertEquals(array_merge($where, $this->defaultWhere), $result);
}
protected function getResultFromProtectedMethod($method, array $args, Request $request)
{
$requestStack = $this->createMock(RequestStack::class);
$requestStack->method('getCurrentRequest')
->willReturn($request);
$fieldRepository = $this->createMock(LeadFieldRepository::class);
$fieldModel = $this->createMock(FieldModel::class);
$fieldModel->method('getRepository')
->willReturn($fieldRepository);
$modelFactory = $this->createMock(ModelFactory::class);
$controller = new FieldApiController(
$this->createMock(CorePermissions::class),
$this->createMock(Translator::class),
$this->createMock(EntityResultHelper::class),
$this->createMock(Router::class),
$this->createMock(FormFactoryInterface::class),
$this->createMock(AppVersion::class),
$requestStack,
$this->createMock(ManagerRegistry::class),
$modelFactory,
$this->createMock(EventDispatcherInterface::class),
$this->createMock(CoreParametersHelper::class),
$fieldModel,
);
$controllerReflection = new \ReflectionClass(FieldApiController::class);
$method = $controllerReflection->getMethod($method);
$method->setAccessible(true);
return $method->invokeArgs($controller, $args);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Doctrine\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector;
use Doctrine\Common\Cache\CacheProvider;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
/**
* Tests that enable debug and profiler to test performance optimizations.
* These tests are slower as debug and profiler are enabled. Add tests here only if you need profiler.
*/
final class LeadApiControllerProfilerTest extends MauticMysqlTestCase
{
/**
* @var array<string,mixed>
*/
protected array $clientOptions = ['debug' => true];
protected function setUp(): void
{
// Disable API just for specific test.
$this->configParams['api_enabled'] = true;
parent::setUp();
}
public function testGetContacts(): void
{
// reset result cache if any
$cache = $this->em->getConfiguration()->getResultCache();
if ($cache instanceof CacheProvider) {
$cache = clone $cache;
$cache->setNamespace('leadCount');
$cache->deleteAll();
}
for ($i = 0; $i < 11; ++$i) {
$contact = new Lead();
$contact->setEmail("contact{$i}@email.com");
$contact->setPoints($i);
$this->em->persist($contact);
}
$this->em->flush();
$this->client->enableProfiler();
// Make 4 requests to see how many count queries we'll get.
$this->getContacts(11);
$this->getContacts(11);
$this->getContacts(5, ['where' => [['col' => 'l.points', 'expr' => 'lt', 'val' => 5]]]);
$this->getContacts(5, ['where' => [['col' => 'l.points', 'expr' => 'lt', 'val' => 5]]]);
// Without the cache, there would be 4 COUNT queries. With the cache, there is just one.
Assert::assertCount(2, $this->findCountQueries());
}
/**
* @param array<string,mixed> $queryParams
*/
private function getContacts(int $expectedCount, array $queryParams = []): void
{
// We have to reset the param counter to emulate 2 requests otherwise the counter will cause the queries to be different.
$leadRepository = $this->em->getRepository(Lead::class);
\assert($leadRepository instanceof LeadRepository);
$reflection = new \ReflectionClass($leadRepository);
$counter = $reflection->getProperty('lastUsedParameterId');
$counter->setAccessible(true);
$counter->setValue($leadRepository, 0);
$this->client->request(Request::METHOD_GET, '/api/contacts', $queryParams);
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
$response = json_decode($this->client->getResponse()->getContent(), true);
Assert::assertSame($expectedCount, (int) $response['total']);
Assert::assertCount($expectedCount, $response['contacts']);
}
/**
* @return array<mixed[]>
*/
private function findCountQueries(): array
{
/** @var DoctrineDataCollector $dbCollector */
$dbCollector = $this->client->getProfile()->getCollector('db');
$allQueries = $dbCollector->getQueries()['default'];
return array_filter(
$allQueries,
fn (array $query) => str_starts_with($query['sql'], 'SELECT COUNT(l.id) as count FROM '.MAUTIC_TABLE_PREFIX.'leads l')
);
}
}

View File

@@ -0,0 +1,559 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Model\ListModel;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
class ListApiControllerFunctionalTest extends MauticMysqlTestCase
{
protected ListModel $listModel;
private string $prefix;
private TranslatorInterface $translator;
protected function setUp(): void
{
parent::setUp();
$this->listModel = static::getContainer()->get('mautic.lead.model.list');
$this->prefix = static::getContainer()->getParameter('mautic.db_table_prefix');
$this->translator = static::getContainer()->get('translator');
}
protected function beforeBeginTransaction(): void
{
$this->resetAutoincrement(['categories']);
}
/**
* @return iterable<array<string|int|null>>
*/
public static function regexOperatorProvider(): iterable
{
yield [
'regexp',
'^{Test|Test string)', // invalid regex: the first parantheses should not be curly
Response::HTTP_BAD_REQUEST,
'error',
];
yield [
'!regexp',
'^(Test|Test string))', // invalid regex: 2 ending parantheses
Response::HTTP_BAD_REQUEST,
'error',
];
yield [
'regexp',
'^(Test|Test string)', // valid regex
Response::HTTP_CREATED,
null,
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('regexOperatorProvider')]
public function testRegexOperatorValidation(string $operator, string $regex, int $expectedResponseCode, ?string $expectedErrorMessage): void
{
$this->client->request(
Request::METHOD_POST,
'/api/segments/new',
[
'name' => 'Regex test',
'filters' => [
[
'glue' => 'and',
'field' => 'city',
'object' => 'lead',
'type' => 'text',
'operator' => $operator,
'properties' => ['filter' => $regex],
],
],
]
);
Assert::assertSame($expectedResponseCode, $this->client->getResponse()->getStatusCode());
if ($expectedErrorMessage) {
Assert::assertStringContainsString(
$expectedErrorMessage,
json_decode($this->client->getResponse()->getContent(), true)['errors'][0]['message'],
$this->client->getResponse()->getContent()
);
}
}
public function testSingleSegmentWorkflow(): void
{
$payload = [
'name' => 'API segment',
'description' => 'Segment created via API test',
'filters' => [
// Legacy structure.
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'filter' => 'Prague',
'display' => null,
'operator' => '=',
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'owner_id',
'type' => 'lookup_id',
'operator' => '=',
'display' => 'John Doe',
'filter' => '4',
],
// Current structure.
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'properties' => ['filter' => 'Prague'],
'operator' => '=',
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'owner_id',
'type' => 'lookup_id',
'operator' => '=',
'display' => 'outdated name',
'filter' => 'outdated_id',
'properties' => [
'display' => 'John Doe',
'filter' => '4',
],
],
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'operator' => '!empty',
'display' => '',
],
],
];
// Create:
$this->client->request('POST', '/api/segments/new', $payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
if (!empty($response['errors'][0])) {
$this->fail($response['errors'][0]['code'].': '.$response['errors'][0]['message']);
}
$segmentId = $response['list']['id'];
$this->assertSame(201, $clientResponse->getStatusCode());
$this->assertGreaterThan(0, $segmentId);
$this->assertEquals($payload['name'], $response['list']['name']);
$this->assertEquals($payload['description'], $response['list']['description']);
$this->assertEquals([
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'properties' => ['filter' => 'Prague'],
'operator' => '=',
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'owner_id',
'type' => 'lookup_id',
'operator' => '=',
'properties' => [
'display' => 'John Doe',
'filter' => '4',
],
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'properties' => ['filter' => 'Prague'],
'operator' => '=',
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'owner_id',
'type' => 'lookup_id',
'operator' => '=',
'properties' => [
'display' => 'John Doe',
'filter' => '4',
],
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'email',
'type' => 'email',
'operator' => '!empty',
'properties' => [
'filter' => null,
],
],
],
$response['list']['filters']
);
// Edit:
$this->client->request('PATCH', "/api/segments/{$segmentId}/edit", ['name' => 'API segment renamed']);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame(200, $clientResponse->getStatusCode());
$this->assertSame($segmentId, $response['list']['id'], 'ID of the created segment does not match with the edited one.');
$this->assertEquals('API segment renamed', $response['list']['name']);
$this->assertEquals($payload['description'], $response['list']['description']);
// Get:
$this->client->request('GET', "/api/segments/{$segmentId}");
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame(200, $clientResponse->getStatusCode());
$this->assertSame($segmentId, $response['list']['id'], 'ID of the created segment does not match with the fetched one.');
$this->assertEquals('API segment renamed', $response['list']['name']);
$this->assertEquals($payload['description'], $response['list']['description']);
// Delete:
$this->client->request('DELETE', "/api/segments/{$segmentId}/delete");
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame(200, $clientResponse->getStatusCode());
$this->assertNull($response['list']['id']);
$this->assertEquals('API segment renamed', $response['list']['name']);
$this->assertEquals($payload['description'], $response['list']['description']);
// Get (ensure it's deleted):
$this->client->request('GET', "/api/segments/{$segmentId}");
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame(404, $clientResponse->getStatusCode());
$this->assertSame(404, $response['errors'][0]['code']);
}
public function testBatchSegmentWorkflow(): void
{
$payload = [
[
'name' => 'API batch segment 1',
'description' => 'Segment created via API test',
'filters' => [
// Legacy structure.
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'filter' => 'Prague',
'display' => null,
'operator' => '=',
],
// Current structure.
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'properties' => ['filter' => 'Prague'],
'operator' => '=',
],
],
],
[
'name' => 'API batch segment 2',
'description' => 'Segment created via API test',
],
];
$this->client->request('POST', '/api/segments/batch/new', $payload);
$clientResponse = $this->client->getResponse();
$response1 = json_decode($clientResponse->getContent(), true);
if (!empty($response1['errors'][0])) {
$this->fail($response1['errors'][0]['code'].': '.$response1['errors'][0]['message']);
}
foreach ($response1['statusCodes'] as $statusCode) {
$this->assertSame(201, $statusCode);
}
foreach ($response1['lists'] as $key => $segment) {
$this->assertGreaterThan(0, $segment['id']);
$this->assertTrue($segment['isPublished']);
$this->assertTrue($segment['isGlobal']);
$this->assertFalse($segment['isPreferenceCenter']);
$this->assertSame($payload[$key]['name'], $segment['name']);
$this->assertSame($payload[$key]['description'], $segment['description']);
$this->assertIsArray($segment['filters']);
// Make a change for the edit request:
$response1['lists'][$key]['isPublished'] = false;
}
// Lets try to create the same segment to see that the values are not re-setted
$this->client->request('PATCH', '/api/segments/batch/edit', $response1['lists']);
$clientResponse = $this->client->getResponse();
$response2 = json_decode($clientResponse->getContent(), true);
if (!empty($response2['errors'][0])) {
$this->fail($response2['errors'][0]['code'].': '.$response2['errors'][0]['message']);
}
foreach ($response2['statusCodes'] as $statusCode) {
$this->assertSame(200, $statusCode);
}
foreach ($response2['lists'] as $key => $segment) {
$this->assertGreaterThan(0, $segment['id']);
$this->assertFalse($segment['isPublished']);
$this->assertTrue($segment['isGlobal']);
$this->assertFalse($segment['isPreferenceCenter']);
$this->assertSame($payload[$key]['name'], $segment['name']);
$this->assertSame($payload[$key]['description'], $segment['description']);
}
$this->assertSame(
[
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'Prague'],
'filter' => 'Prague',
'display' => null,
],
[
'object' => 'lead',
'glue' => 'and',
'field' => 'city',
'type' => 'text',
'operator' => '=',
'properties' => ['filter' => 'Prague'],
'filter' => 'Prague',
'display' => null,
],
],
$response2['lists'][0]['filters']
);
$this->assertSame([], $response2['lists'][1]['filters']);
}
public function testWeGet422ResponseCodeIfSegmentIsBeingUsedInSomeCampaignAndWeUnpublishIt(): void
{
$segmentName = 'Segment1';
$segment = new LeadList();
$segment->setName($segmentName);
$segment->setPublicName($segmentName);
$segment->setAlias(mb_strtolower($segmentName));
$segment->setIsPublished(true);
$this->em->persist($segment);
$campaign = new Campaign();
$campaignName = 'Campaign1';
$campaign->setName($campaignName);
$this->em->persist($campaign);
$this->em->flush();
// insert unpublished record
$this->connection->insert($this->prefix.'campaign_leadlist_xref', [
'campaign_id' => $campaign->getId(),
'leadlist_id' => $segment->getId(),
]);
$this->client->request('PATCH', "/api/segments/{$segment->getId()}/edit", ['isPublished' => 0]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $clientResponse->getStatusCode());
Assert::assertArrayHasKey('errors', $response);
$errorMessage = $this->translator->trans(
'mautic.lead.lists.used_in_campaigns',
[
'%count%' => '1',
'%campaignNames%' => '"'.$campaignName.'"',
],
'validators'
);
Assert::assertStringContainsString($errorMessage, $response['errors'][0]['message']);
}
public function testWeGet200ResponseCodeIfSegmentIsNotUsedInCampaignsAndWeUnpublishIt(): void
{
$segmentName = 'Segment1';
$segment = new LeadList();
$segment->setName($segmentName);
$segment->setPublicName($segmentName);
$segment->setAlias(mb_strtolower($segmentName));
$segment->setIsPublished(true);
$this->em->persist($segment);
$campaign = new Campaign();
$campaign->setName('campaign1');
$this->em->persist($campaign);
$this->em->flush();
$this->client->request('PATCH', "/api/segments/{$segment->getId()}/edit", ['isPublished' => 0]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
Assert::assertSame(Response::HTTP_OK, $clientResponse->getStatusCode());
Assert::assertArrayNotHasKey('errors', $response);
}
public function testUnpublishUsedSingleSegment(): void
{
$filter = [[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'operator' => '!empty',
'display' => '',
]];
$list1 = $this->saveSegment('s1', 's1', $filter);
$filter = [[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => [
'filter' => [$list1->getId()],
],
'display' => '',
]];
$list2 = $this->saveSegment('s2', 's2', $filter);
$this->em->clear();
$expectedErrorMessage = sprintf('leadlist: This segment is used in %s, please go back and check segments before unpublishing', $list2->getName());
$this->client->request('PATCH', "/api/segments/{$list1->getId()}/edit", ['name' => 'API segment renamed', 'isPublished' => false]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $clientResponse->getStatusCode());
$this->assertSame($response['errors'][0]['message'], $expectedErrorMessage);
}
public function testUnpublishUsedBatchSegment(): void
{
$filter = [[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'operator' => '!empty',
'display' => '',
]];
$list1 = $this->saveSegment('s1', 's1', $filter);
$filter = [[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => [
'filter' => [$list1->getId()],
],
'display' => '',
]];
$list2 = $this->saveSegment('s2', 's2', $filter);
$this->em->clear();
$expectedErrorMessage = sprintf('leadlist: This segment is used in %s, please go back and check segments before unpublishing', $list2->getName());
$segments = [
['id' => $list1->getId(), 'isPublished' => false],
['id' => $list2->getId(), 'isPublished' => false],
];
$this->client->request('PATCH', '/api/segments/batch/edit', $segments);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response['statusCodes'][0]);
$this->assertSame($response['errors'][0]['message'], $expectedErrorMessage);
$this->assertSame(Response::HTTP_OK, $response['statusCodes'][1]);
}
public function testSegmentWithCategory(): void
{
$categoryPayload = [
'title' => 'API Cat',
'alias' => 'kitty',
'bundle' => 'segment',
];
$this->client->request('POST', '/api/categories/new', $categoryPayload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$categoryId = $response['category']['id'];
$segmentPayload = [
'name' => 'API segment',
'description' => 'Segment created via API test',
'category' => $categoryId,
];
// Create:
$this->client->request('POST', '/api/segments/new', $segmentPayload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
if (!empty($response['errors'][0])) {
$this->fail($response['errors'][0]['code'].': '.$response['errors'][0]['message']);
}
$segmentId = $response['list']['id'];
// Get segment with category by id:
$this->client->request('GET', "/api/segments/{$segmentId}");
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertTrue($clientResponse->isOk());
$this->assertEquals($segmentPayload['category'], $response['list']['category']['id']);
// Search segments by category:
$this->client->request('GET', '/api/segments?search=category:kitty');
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertTrue($clientResponse->isOk());
$this->assertCount(1, $response['lists']);
}
private function saveSegment(string $name, string $alias, array $filters = [], ?LeadList $segment = null): LeadList
{
$segment ??= new LeadList();
$segment->setName($name)->setPublicName($name)->setAlias($alias)->setFilters($filters);
$this->listModel->saveEntity($segment);
return $segment;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller\Api;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Symfony\Component\HttpFoundation\Response;
class TagApiControllerFunctionalTest extends MauticMysqlTestCase
{
public function testTagWorkflow(): void
{
$tag1Payload = ['tag' => 'test_tag'];
// Create new tag
$this->client->request('POST', '/api/tags/new', $tag1Payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$tagId = $response['tag']['id'];
$this->assertResponseStatusCodeSame(Response::HTTP_CREATED, 'Return code must be 201.');
$this->assertGreaterThan(0, $tagId);
$this->assertEquals($tag1Payload['tag'], $response['tag']['tag']);
// Try to create tag with same name
$this->client->request('POST', '/api/tags/new', $tag1Payload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertResponseIsSuccessful('Return code must be 200.');
// The same tag id should be returned
$this->assertEquals($tagId, $response['tag']['id'], 'ID of created tag with the same name does not match. Possible duplicates.');
$this->assertEquals($tag1Payload['tag'], $response['tag']['tag']);
// Edit tag name
$tag1RenamePayload = ['tag' => 'tag_renamed'];
$this->client->request('PATCH', "/api/tags/{$tagId}/edit", $tag1RenamePayload);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertResponseIsSuccessful('Return code must be 200.');
$this->assertSame($tagId, $response['tag']['id'], 'ID of the created tag does not match with the edited one.');
$this->assertEquals($tag1RenamePayload['tag'], $response['tag']['tag']);
// Get tag
$this->client->request('GET', "/api/tags/{$tagId}");
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertResponseIsSuccessful();
$this->assertSame($tagId, $response['tag']['id'], 'ID of the created tag does not match with the fetched one.');
$this->assertEquals($tag1RenamePayload['tag'], $response['tag']['tag']);
// Delete:
$this->client->request('DELETE', "/api/tags/{$tagId}/delete");
$clientResponse = $this->client->getResponse();
$this->assertResponseIsSuccessful($clientResponse->getContent());
// Get (ensure it's deleted):
$this->client->request('GET', "/api/tags/{$tagId}");
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertResponseStatusCodeSame(404);
$this->assertSame(404, $response['errors'][0]['code']);
}
/**
* Test if whitespace before or after tag name is removed and duplicates are not created.
*/
public function testWhitespaceBeforeAndAfterNameNotCreatingDuplicates(): void
{
$tagName = 'test';
$whitespaceTestPayload = ['test', 'test ', ' test', "test\t", "\ttest"];
$tagId = null;
foreach ($whitespaceTestPayload as $payload) {
$this->client->request('POST', '/api/tags/new', ['tag' => $payload]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
// whitespace before and after tag name should be removed, name should be the same for each tag
$this->assertEquals($tagName, $response['tag']['tag']);
if (null === $tagId) {
$tagId = $response['tag']['id'];
} else {
$this->assertSame($tagId, $response['tag']['id'], 'ID of created tag does not match. Possible duplicates.');
}
}
}
/**
* Test if special characters in tag name are encoded and duplicates are not created.
*/
public function testEncodedCharactersNotCreatingDuplicates(): void
{
$tagInputName = 'hello" world';
$this->client->request('POST', '/api/tags/new', ['tag' => $tagInputName]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$tagId = $response['tag']['id'];
$this->assertGreaterThan(0, $tagId);
$this->assertEquals($tagInputName, $response['tag']['tag']);
// Try to create duplicate
$this->client->request('POST', '/api/tags/new', ['tag' => $tagInputName]);
$clientResponse = $this->client->getResponse();
$response = json_decode($clientResponse->getContent(), true);
$this->assertSame($tagId, $response['tag']['id'], 'ID of created tag does not match. Possible duplicates.');
}
public function testTagCreationWithoutRequiredData(): void
{
// Sending an empty payload should return a 500 server error
// TODO ensure that the server sends back a 400 status code instead
$this->client->request('POST', '/api/tags/new', []);
$clientResponse = $this->client->getResponse();
$this->assertResponseStatusCodeSame(500);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Response;
final class AuditLogControllerTest extends MauticMysqlTestCase
{
use CreateTestEntitiesTrait;
private const SALES_USER = 'sales';
/**
* @throws OptimisticLockException
* @throws ORMException
*/
public function testBatchExportActionAsAdmin(): void
{
$contact = $this->createLead('TestFirstName');
$this->em->persist($contact);
$this->em->flush();
$this->client->request('GET', '/s/contacts/auditlog/batchExport/'.$contact->getId());
$this->assertResponseIsSuccessful();
}
public function testBatchExportActionAsUserNotPermission(): void
{
$contact = $this->createLead('TestFirstName');
$this->em->persist($contact);
$this->em->flush();
$user = $this->em->getRepository(User::class)->findOneBy(['username' => self::SALES_USER]);
$this->loginUser($user);
$this->client->request('GET', '/s/contacts/auditlog/batchExport/'.$contact->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\ProjectBundle\Entity\Project;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class CompanyControllerTest extends MauticMysqlTestCase
{
private int $company1Id;
private int $company2Id;
protected function setUp(): void
{
parent::setUp();
$companiesData = [
1 => [
'name' => 'Amazon',
'state' => 'Washington',
'city' => 'Seattle',
'country' => 'United States',
'industry' => 'Goods',
],
2 => [
'name' => 'Google',
'state' => 'Washington',
'city' => 'Seattle',
'country' => 'United States',
'industry' => 'Services',
],
];
/** @var \Mautic\LeadBundle\Model\CompanyModel $model */
$model = self::getContainer()->get('mautic.lead.model.company');
foreach ($companiesData as $i => $companyData) {
$company = new Company();
$company->setIsPublished(true)
->setName($companyData['name'])
->setState($companyData['state'])
->setCity($companyData['city'])
->setCountry($companyData['country'])
->setIndustry($companyData['industry']);
$model->saveEntity($company);
$this->{'company'.$i.'Id'} = $company->getId();
}
}
/**
* Get company's view page.
*/
public function testViewActionCompany(): void
{
$this->client->request('GET', '/s/companies/view/'.$this->company1Id);
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$model = self::getContainer()->get('mautic.lead.model.company');
$company = $model->getEntity($this->company1Id);
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$this->assertStringContainsString($company->getName(), $clientResponseContent, 'The return must contain the name of company');
}
/**
* Get company's edit page.
*/
public function testEditActionCompany(): void
{
$this->client->request('GET', '/s/companies/edit/'.$this->company1Id);
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$model = self::getContainer()->get('mautic.lead.model.company');
$company = $model->getEntity($this->company1Id);
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
$this->assertStringContainsString('Edit Company '.$company->getName(), $clientResponseContent, 'The return must contain \'Edit Company\' text');
}
/* Get company contacts list */
public function testListCompanyContacts(): void
{
/** @var \Mautic\LeadBundle\Model\CompanyModel $companyModel */
$companyModel = self::getContainer()->get('mautic.lead.model.company');
$leadModel = self::getContainer()->get('mautic.lead.model.lead');
$company1 = $companyModel->getEntity($this->company1Id);
// Create a lead linked to the first company
$lead1 = new Lead();
$lead1
->setFirstname('lead')
->setLastname('for '.$company1->getName());
$leadModel->saveEntity($lead1);
$companyModel->addLeadToCompany($company1, $lead1);
// Create a lead not linked to a company
$lead2 = new Lead();
$lead2
->setFirstname('lead')
->setLastname('without company');
$leadModel->saveEntity($lead2);
// Create a lead not linked to a company, but with `ids` in it's name (see https://github.com/mautic/mautic/issues/12415)
$lead3 = new Lead();
$lead3
->setFirstname('lead')
->setLastname('without company')
->setEmail('example@idstart.com');
$leadModel->saveEntity($lead3);
$crawler = $this->client->request('GET', '/s/company/'.$this->company1Id.'/contacts/');
$leadsTableRows = $crawler->filterXPath("//table[@id='leadTable']//tbody//tr");
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertEquals(1, $leadsTableRows->count(), $crawler->html());
$crawler = $this->client->request('GET', '/s/company/'.$this->company2Id.'/contacts/');
$leadsTableRows = $crawler->filterXPath("//table[@id='leadTable']//tbody//tr");
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertEquals(0, $leadsTableRows->count(), $crawler->html());
}
/**
* Get company's create page.
*/
public function testNewActionCompany(): void
{
$this->client->request('GET', '/s/companies/new/');
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
}
public function testNonExitingCompanyIsRedirected(): void
{
$this->client->followRedirects(false);
$this->client->request(
Request::METHOD_GET,
's/companies/view/1000',
);
$this->assertEquals(true, $this->client->getResponse()->isRedirect('/s/companies'));
}
public function testNewCompanyMergeButtonVisible(): void
{
$this->client->request('GET', '/s/companies/new/');
$clientResponse = $this->client->getResponse();
$clientResponseContent = $clientResponse->getContent();
$this->assertEquals(Response::HTTP_OK, $clientResponse->getStatusCode());
// Use the Crawler to parse the HTML content
$crawler = new \Symfony\Component\DomCrawler\Crawler($clientResponseContent);
// Check for specific buttons by their IDs
$applyButton = $crawler->filter('#company_buttons_apply');
$saveButton = $crawler->filter('#company_buttons_save');
$cancelButton = $crawler->filter('#company_buttons_cancel');
$mergeButton = $crawler->filter('#company_buttons_merge');
$this->assertCount(1, $applyButton, 'Apply button not found');
$this->assertCount(1, $saveButton, 'Save button not found');
$this->assertCount(1, $cancelButton, 'Cancel button not found');
$this->assertCount(0, $mergeButton, 'Merge button found');
}
public function testCompanyWithProject(): void
{
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/companies/edit/'.$this->company1Id);
$form = $crawler->selectButton('Save')->form();
$form['company[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedCompany = $this->em->find(Company::class, $this->company1Id);
$this->assertSame($project->getId(), $savedCompany->getProjects()->first()->getId());
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\LeadBundle\Entity\Company;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class CompanyProjectSearchFunctionalTest 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');
$companyAlpha = $this->createCompany('Company Alpha');
$companyBeta = $this->createCompany('Company Beta');
$this->createCompany('Company Gamma');
$this->createCompany('Company Delta');
$companyAlpha->addProject($projectOne);
$companyAlpha->addProject($projectTwo);
$companyBeta->addProject($projectTwo);
$companyBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/companies', '/s/companies']);
}
/**
* @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' => ['Company Alpha', 'Company Beta'],
'unexpectedEntities' => ['Company Gamma', 'Company Delta'],
];
yield 'search by one project AND company name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Company Beta'],
'unexpectedEntities' => ['Company Alpha', 'Company Gamma', 'Company Delta'],
];
yield 'search by one project OR company name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Company Alpha', 'Company Beta', 'Company Gamma'],
'unexpectedEntities' => ['Company Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Company Gamma', 'Company Delta'],
'unexpectedEntities' => ['Company Alpha', 'Company Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Company Beta'],
'unexpectedEntities' => ['Company Alpha', 'Company Gamma', 'Company Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Company Gamma', 'Company Delta'],
'unexpectedEntities' => ['Company Alpha', 'Company Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Company Alpha', 'Company Beta'],
'unexpectedEntities' => ['Company Gamma', 'Company Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Company Alpha', 'Company Gamma', 'Company Delta'],
'unexpectedEntities' => ['Company Beta'],
];
}
private function createCompany(string $name): Company
{
$company = new Company();
$company->setName($name);
$this->em->persist($company);
return $company;
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller;
use Doctrine\DBAL\Schema\Column;
use Mautic\CoreBundle\Doctrine\Helper\ColumnSchemaHelper;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadField;
use Symfony\Component\HttpFoundation\Request;
class FieldControllerTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testLengthValidationOnLabelFieldWhenAddingCustomFieldFailure(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/fields/new');
$form = $crawler->selectButton('Save & Close')->form();
$label = 'The leading Drupal Cloud platform to securely develop, deliver, and run websites, applications, and content. Top-of-the-line hosting options are paired with automated testing and development tools. Documentation is also included for the following components';
$form['leadfield[label]']->setValue($label);
$crawler = $this->client->submit($form);
$labelErrorMessage = trim($crawler->filter('#leadfield_label')->nextAll()->text());
$maxLengthErrorMessageTemplate = 'Label value cannot be longer than 191 characters';
$this->assertEquals($maxLengthErrorMessageTemplate, $labelErrorMessage);
}
public function testLengthValidationOnLabelFieldWhenAddingCustomFieldSuccess(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/fields/new');
$form = $crawler->selectButton('Save & Close')->form();
$label = 'Test value for custom field 4';
$form['leadfield[label]']->setValue($label);
$crawler = $this->client->submit($form);
$field = $this->em->getRepository(LeadField::class)->findOneBy(['label' => $label]);
$this->assertNotNull($field);
}
public function testCloneFieldSubmission(): void
{
$field = new LeadField();
$field->setLabel('Field to be cloned');
$field->setAlias('field_to_be_cloned');
$field->setType('text');
$this->em->getRepository(LeadField::class)->saveEntity($field);
$this->em->clear();
$field = $this->em->getRepository(LeadField::class)->findOneBy(['alias' => 'field_to_be_cloned']);
$this->assertNotNull($field);
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/fields/clone/'.$field->getId());
$this->assertResponseStatusCodeSame(200);
$this->assertSelectorTextContains('h1', 'New Custom Field');
$form = $crawler->selectButton('Save & Close')->form();
$form['leadfield[label]']->setValue('Cloned Field');
$this->client->submit($form);
$this->assertResponseStatusCodeSame(200);
$clonedField = $this->em->getRepository(LeadField::class)->findOneBy(['label' => 'Cloned Field']);
$this->assertNotNull($clonedField);
$this->assertNotEquals($field->getId(), $clonedField->getId());
}
public function testCloneNonExistentField(): void
{
$this->client->request(Request::METHOD_GET, '/s/contacts/fields/clone/9999');
$this->assertResponseStatusCodeSame(404);
}
#[\PHPUnit\Framework\Attributes\DataProvider('getStringTypeFieldsArray')]
public function testMaxCharLengthFieldValidationOnStringTypeWhenAddingCustomFieldFailure(string $label, string $type): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/fields/new');
$form = $crawler->selectButton('Save & Close')->form();
$form['leadfield[label]']->setValue($label);
$form['leadfield[object]']->setValue('lead');
$form['leadfield[type]']->setValue($type);
$form['leadfield[charLengthLimit]']->setValue('260');
$crawler = $this->client->submit($form);
$errorMessage = trim($crawler->filter('#leadfield_charLengthLimit')->nextAll()->text());
$maxCharLimitErrorMessage = 'This value should be between 1 and 191.';
$this->assertEquals($maxCharLimitErrorMessage, $errorMessage);
}
#[\PHPUnit\Framework\Attributes\DataProvider('getStringTypeFieldsArray')]
public function testMaxCharLengthFieldValidationOnStringTypeWhenAddingCustomFieldSuccess(string $label, string $type): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/fields/new');
$form = $crawler->selectButton('Save & Close')->form();
$form['leadfield[label]']->setValue($label);
$form['leadfield[object]']->setValue('lead');
$form['leadfield[type]']->setValue($type);
$form['leadfield[charLengthLimit]']->setValue('191');
$this->client->submit($form);
$field = $this->em->getRepository(LeadField::class)->findOneBy(['label' => $label]);
$this->assertNotNull($field);
}
/**
* @return array<mixed, mixed>
*/
public static function getStringTypeFieldsArray(): iterable
{
yield ['test_email', 'email'];
yield ['test_text', 'text'];
}
#[\PHPUnit\Framework\Attributes\DataProvider('getCustomFields')]
public function testCustomFieldCharacterLengthLimit(string $label, string $type): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/fields/new');
$form = $crawler->selectButton('Save & Close')->form();
$form['leadfield[label]']->setValue($label);
$form['leadfield[object]']->setValue('lead');
$form['leadfield[type]']->setValue($type);
$this->client->submit($form);
$field = $this->em->getRepository(LeadField::class)->findOneBy(['label' => $label]);
$this->assertNotNull($field);
/** @var ColumnSchemaHelper $helper */
$helper = $this->getContainer()->get('mautic.schema.helper.column');
// Table name to check the fields.
$name = 'leads';
$schemaHelper = $helper->setName($name);
/** @var Column $fieldsDescription */
$fieldsDescription = $schemaHelper->getColumns()[$label];
$this->assertSame(191, $fieldsDescription->getLength());
}
/**
* @return array<mixed, mixed>
*/
public static function getCustomFields(): iterable
{
yield ['test_timezone', 'timezone'];
yield ['test_locale', 'locale'];
yield ['test_country', 'country'];
yield ['test_phone', 'tel'];
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\LeadField;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Field\InputFormField;
use Symfony\Component\HttpFoundation\Request;
class FieldFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
#[\PHPUnit\Framework\Attributes\DataProvider('provideFieldLength')]
public function testNewFieldVarcharFieldLength(int $expectedLength, ?int $inputLength = null): void
{
$fieldModel = static::getContainer()->get('mautic.lead.model.field');
$field = $this->createField('a', 'text', [], $inputLength);
$fieldModel->saveEntity($field);
$tablePrefix = static::getContainer()->getParameter('mautic.db_table_prefix');
$columns = $this->connection->createSchemaManager()->listTableColumns("{$tablePrefix}leads");
$this->assertEquals($expectedLength, $columns[$field->getAlias()]->getLength());
}
public function testNewMultiSelectField(): void
{
$fieldModel = static::getContainer()->get('mautic.lead.model.field');
$field = $this->createField('s', 'select', ['properties' => ['list' => ['choice_a' => 'Choice A']]]);
$fieldModel->saveEntity($field);
$tablePrefix = static::getContainer()->getParameter('mautic.db_table_prefix');
$columns = $this->connection->createSchemaManager()->listTableColumns("{$tablePrefix}leads");
$this->assertArrayHasKey('field_s', $columns);
}
public function testNewDateField(): void
{
$crawler = $this->client->request(Request::METHOD_GET, 's/contacts/fields/new');
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
$form = $crawler->selectButton('Save')->form();
$form['leadfield[label]']->setValue('Best Date Ever');
$form['leadfield[type]']->setValue('date');
$this->client->submit($form);
$text = strip_tags($this->client->getResponse()->getContent());
Assert::assertTrue($this->client->getResponse()->isOk(), $text);
Assert::assertStringNotContainsString('New Custom Field', $text);
Assert::assertStringNotContainsString('This form should not contain extra fields.', $text);
Assert::assertStringContainsString('Edit Custom Field - Best Date Ever', $text);
}
public function testNewSelectField(): void
{
$crawler = $this->client->request(Request::METHOD_GET, 's/contacts/fields/new');
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
$domDocument = $crawler->getNode(0)->ownerDocument;
$inputLabel = $domDocument->createElement('input');
$inputLabel->setAttribute('type', 'text');
$inputLabel->setAttribute('name', 'leadfield[properties][list][0][label]');
$inputValue = $domDocument->createElement('input');
$inputValue->setAttribute('type', 'text');
$inputValue->setAttribute('name', 'leadfield[properties][list][0][value]');
$form = $crawler->selectButton('Save')->form();
$form->set(new InputFormField($inputLabel));
$form->set(new InputFormField($inputValue));
$form['leadfield[label]']->setValue('Test select field');
$form['leadfield[type]']->setValue('select');
$form['leadfield[properties][list][0][label]']->setValue('Label 1');
$form['leadfield[properties][list][0][value]']->setValue('Value 1');
$this->client->submit($form);
$text = strip_tags($this->client->getResponse()->getContent());
Assert::assertTrue($this->client->getResponse()->isOk(), $text);
Assert::assertStringNotContainsString('New Custom Field', $text);
Assert::assertStringNotContainsString('This form should not contain extra fields.', $text);
Assert::assertStringContainsString('Edit Custom Field - Test select field', $text);
}
/**
* @param array<string, string> $properties
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataForCreatingNewBooleanField')]
public function testCreatingNewBooleanField(array $properties, string $expectedMessage): void
{
$crawler = $this->client->request(Request::METHOD_GET, 's/contacts/fields/new');
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
$domDocument = $crawler->getNode(0)->ownerDocument;
$yesLabel = $domDocument->createElement('input');
$yesLabel->setAttribute('type', 'text');
$yesLabel->setAttribute('name', 'leadfield[properties][yes]');
$noLabel = $domDocument->createElement('input');
$noLabel->setAttribute('type', 'text');
$noLabel->setAttribute('name', 'leadfield[properties][no]');
$form = $crawler->selectButton('Save')->form();
$form->set(new InputFormField($yesLabel));
$form->set(new InputFormField($noLabel));
$form['leadfield[label]']->setValue('Request a meeting');
$form['leadfield[type]']->setValue('boolean');
$form['leadfield[object]']->setValue('lead');
$form['leadfield[group]']->setValue('core');
$form['leadfield[properties][yes]']->setValue($properties['yes'] ?? '');
$form['leadfield[properties][no]']->setValue($properties['no'] ?? '');
$this->client->submit($form);
$this->assertTrue($this->client->getResponse()->isOk());
$text = strip_tags($this->client->getResponse()->getContent());
Assert::assertStringNotContainsString($expectedMessage, $text);
}
/**
* @return iterable<string, array<int, string|array<string, string>>>
*/
public static function dataForCreatingNewBooleanField(): iterable
{
yield 'No properties' => [
[],
'A \'positive\' label is required.',
];
yield 'Only Yes' => [
[
'yes' => 'Yes',
],
'A \'negative\' label is required.',
];
yield 'Only No' => [
[
'no' => 'No',
],
'A \'positive\' label is required.',
];
}
public function testCheckDefaultBooleanFieldSetting(): void
{
$crawler = $this->client->request(Request::METHOD_GET, 's/contacts/fields/new');
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
// Check if the radio button with value 0 is checked and value 1 is not
Assert::assertNotNull(
$crawler->filter('#leadfield_default_template_boolean_0')->attr('checked')
);
Assert::assertNull(
$crawler->filter('#leadfield_default_template_boolean_1')->attr('checked')
);
}
public function testFieldsSearchByIds(): void
{
$urlEncodedSearch = urlencode('ids:2,3');
$this->client->request(Request::METHOD_GET, "/s/contacts/fields?search={$urlEncodedSearch}");
$this->assertResponseIsSuccessful();
Assert::assertStringContainsString('First Name', $this->client->getResponse()->getContent());
Assert::assertStringContainsString('Last Name', $this->client->getResponse()->getContent());
}
/**
* @param array<string, mixed> $parameters
*/
private function createField(string $suffix, string $type = 'text', array $parameters = [], ?int $charLength = null): LeadField
{
$field = new LeadField();
$field->setName("Field $suffix");
$field->setAlias("field_$suffix");
$field->setDateAdded(new \DateTime());
$field->setDateAdded(new \DateTime());
$field->setDateModified(new \DateTime());
$field->setType($type);
if (!empty($charLength)) {
$field->setCharLengthLimit($charLength);
}
$field->setObject('lead');
isset($parameters['properties']) && $field->setProperties($parameters['properties']);
return $field;
}
/**
* @return iterable<array<mixed>>
*/
public static function provideFieldLength(): iterable
{
yield [ClassMetadataBuilder::MAX_VARCHAR_INDEXED_LENGTH, ClassMetadataBuilder::MAX_VARCHAR_INDEXED_LENGTH];
yield [64, null];
}
}

View File

@@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\ImportCommand;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\CompanyLead;
use Mautic\LeadBundle\Entity\Import;
use Mautic\LeadBundle\Entity\ImportRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadEventLog;
use Mautic\LeadBundle\Entity\LeadEventLogRepository;
use Mautic\LeadBundle\Entity\LeadField;
use Mautic\LeadBundle\Entity\LeadFieldRepository;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\UserBundle\Entity\Permission;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Form;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
final class ImportControllerTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testImportWithoutFile(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$form = $crawler->selectButton('Upload')->form();
$crawler = $this->client->submit($form);
Assert::assertStringContainsString('Please select a CSV file to upload', $crawler->html(), $crawler->html());
}
/**
* Setting the phone field as required to test the validation.
* Phone is not part of the csv fixture so it won't be auto-mapped.
*/
public function testImportMappingRequiredFieldValidation(): void
{
$this->setPhoneFieldIsRequired(true);
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$uploadForm = $crawler->selectButton('Upload')->form();
$file = new UploadedFile(__DIR__.'/../Fixtures/contacts.csv', 'contacs.csv', 'text/csv');
$uploadForm['lead_import[file]']->setValue((string) $file);
$crawler = $this->client->submit($uploadForm);
$mappingForm = $crawler->selectButton('Import')->form();
$crawler = $this->client->submit($mappingForm);
Assert::assertStringContainsString('Some required fields are missing. You must map the field "Phone."', $crawler->html());
}
#[\PHPUnit\Framework\Attributes\DataProvider('validateDataProvider')]
public function testImportMappingAndImport(string $skipIfExist, string $expectedName): void
{
$this->createLead('john@doe.email', 'Johny');
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$uploadForm = $crawler->selectButton('Upload')->form();
$file = new UploadedFile(__DIR__.'/../Fixtures/contacts.csv', 'contacs.csv', 'text/csv');
$uploadForm['lead_import[file]']->setValue((string) $file);
$crawler = $this->client->submit($uploadForm);
$mappingForm = $crawler->selectButton('Import')->form();
$mappingForm['lead_field_import[skip_if_exists]']->setValue($skipIfExist);
$crawler = $this->client->submit($mappingForm);
Assert::assertStringContainsString('Import process was successfully created. You will be notified when finished.', $crawler->html());
/** @var ImportRepository $importRepository */
$importRepository = $this->em->getRepository(Import::class);
/** @var Import $importEntity */
$importEntity = $importRepository->findOneBy(['originalFile' => 'contacts.csv']);
$fields = ['email' => 'email', 'firstname' => 'firstname', 'lastname' => 'lastname'];
Assert::assertNotNull($importEntity);
Assert::assertSame(2, $importEntity->getLineCount());
Assert::assertSame(Import::QUEUED, $importEntity->getStatus());
Assert::assertSame('lead', $importEntity->getObject());
Assert::assertSame($fields, $importEntity->getProperties()['fields']);
Assert::assertSame(array_values($fields), $importEntity->getProperties()['headers']);
$this->testSymfonyCommand(ImportCommand::COMMAND_NAME);
$this->em->clear();
/** @var Import $importEntity */
$importEntity = $importRepository->findOneBy(['originalFile' => 'contacts.csv']);
Assert::assertNotNull($importEntity);
Assert::assertSame(2, $importEntity->getLineCount());
Assert::assertSame(1, $importEntity->getInsertedCount());
Assert::assertSame(1, $importEntity->getUpdatedCount());
Assert::assertSame(Import::IMPORTED, $importEntity->getStatus());
/** @var LeadRepository $importRepository */
$leadRepository = $this->em->getRepository(Lead::class);
/** @var Lead[] $contacts */
$contacts = $leadRepository->findBy(['email' => ['john@doe.email', 'ferda@mravenec.email']], ['email' => 'desc']);
Assert::assertSame($expectedName, $contacts[0]->getFirstname());
Assert::assertCount(2, $contacts);
}
public function testContactPermissionsAreFollowedDuringImport(): void
{
$filename = 'import-contact-permissions.csv';
$permission = [
'lead:leads' => ['viewown', 'viewother', 'editown'],
'lead:imports' => ['view', 'create', 'edit'],
];
$role = $this->createRole(false, $permission);
$this->createPermission('lead:imports', $role, 1024);
$this->createPermission('lead:leads', $role, 14);
$user = $this->createUser($role);
$this->createLead('existing-other@email.tld', 'Existing-other-before');
$lead = $this->createLead('existing-owned@email.tld', 'Existing-owned-before');
$lead->setOwner($user);
$this->em->persist($lead);
$this->createCompanyForLead($lead, 'Company One');
$this->em->flush();
$this->em->clear();
// Login newly created non-admin user
$this->loginUser($user);
$this->client->setServerParameter('PHP_AUTH_USER', $user->getUserIdentifier());
$this->client->setServerParameter('PHP_AUTH_PW', 'Maut1cR0cks!');
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$uploadForm = $crawler->selectButton('Upload')->form();
$file = new UploadedFile(dirname(__FILE__).'/../Fixtures/'.$filename, 'contacts.csv', 'text/csv');
$uploadForm['lead_import[file]']->setValue((string) $file);
$crawler = $this->client->submit($uploadForm);
$mappingForm = $crawler->selectButton('Import')->form();
$this->selectCompanyMapping($crawler, $mappingForm);
$crawler = $this->client->submit($mappingForm);
Assert::assertStringContainsString('Import process was successfully created.', $crawler->html());
$importRepository = $this->em->getRepository(Import::class);
\assert($importRepository instanceof ImportRepository);
$importEntity = $importRepository->findOneBy(['originalFile' => $filename]);
Assert::assertInstanceOf(Import::class, $importEntity);
Assert::assertSame($user->getId(), $importEntity->getCreatedBy());
Assert::assertSame($user->getId(), $importEntity->getModifiedBy());
Assert::assertSame(Import::QUEUED, $importEntity->getStatus());
$this->testSymfonyCommand(ImportCommand::COMMAND_NAME);
$this->em->clear();
$importEntity = $importRepository->findOneBy(['originalFile' => $filename]);
Assert::assertInstanceOf(Import::class, $importEntity);
Assert::assertSame(3, $importEntity->getLineCount(), '3 rows should be processed as the CSV file contains 3 rows.');
Assert::assertSame(0, $importEntity->getInsertedCount(), 'No row should be inserter as the user does not have permission to create contacts.');
Assert::assertSame(1, $importEntity->getUpdatedCount(), 'There should be one update as the user has the permission to edit his own contacts.');
Assert::assertSame(Import::IMPORTED, $importEntity->getStatus());
$leadRepository = $this->em->getRepository(Lead::class);
\assert($leadRepository instanceof LeadRepository);
/** @var Lead[] $contacts */
$contacts = $leadRepository->findBy([], ['id' => 'asc']);
Assert::assertCount(2, $contacts, 'There should not be any contact inserted as the user does not have permission to create contacts.');
Assert::assertSame('Existing-other-before', $contacts[0]->getFirstname(), 'This contact should not be updated as the user does not have permission to edit others.');
Assert::assertSame('Existing-owned-after', $contacts[1]->getFirstname(), 'This contact should be updated as the user has permission to edit own.');
$eventLogRepository = $this->em->getRepository(LeadEventLog::class);
\assert($eventLogRepository instanceof LeadEventLogRepository);
/** @var LeadEventLog[] $logs */
$logs = $eventLogRepository->findBy(['bundle' => 'lead', 'object' => 'import'], ['id' => 'asc']);
Assert::assertCount(3, $logs, 'There should be 3 logs connected with the import.');
$this->assertInsufficientPermissionError($logs[0], $user);
$this->assertInsufficientPermissionError($logs[1], $user);
Assert::assertSame('updated', $logs[2]->getAction());
Assert::assertArrayNotHasKey('error', $logs[2]->getProperties());
}
public function testImportPublishAndUnpublish(): void
{
$permission = [
'lead:imports' => ['view', 'create', 'edit'],
];
$role = $this->createRole(false, $permission);
$this->createPermission('lead:imports', $role, 36);
$user = $this->createUser($role);
$this->em->flush();
$this->em->clear();
// Login newly created non-admin user
$this->loginUser($user);
$this->client->setServerParameter('PHP_AUTH_USER', $user->getUsername());
$this->client->setServerParameter('PHP_AUTH_PW', 'Maut1cR0cks!');
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$uploadForm = $crawler->selectButton('Upload')->form();
$file = new UploadedFile(dirname(__FILE__).'/../Fixtures/contacts.csv', 'contacs.csv', 'itext/csv');
$uploadForm['lead_import[file]']->setValue((string) $file);
$crawler = $this->client->submit($uploadForm);
$mappingForm = $crawler->selectButton('Import')->form();
$crawler = $this->client->submit($mappingForm);
Assert::assertStringContainsString(
'Import process was successfully created. But it will not be processed as you do not have permission to publish.',
$crawler->html()
);
/** @var ImportRepository $importRepository */
$importRepository = $this->em->getRepository(Import::class);
/** @var Import $importEntity */
$importEntity = $importRepository->findOneBy(['originalFile' => 'contacts.csv']);
Assert::assertNotNull($importEntity);
Assert::assertFalse($importEntity->getIsPublished());
Assert::assertSame(Import::STOPPED, $importEntity->getStatus());
}
public function testImportFailedWithImportFailedException(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$uploadForm = $crawler->selectButton('Upload')->form();
$file = new UploadedFile(
dirname(__FILE__).'/../Fixtures/contacts.csv',
'contacs.csv',
'itext/csv'
);
$uploadForm['lead_import[file]']->setValue((string) $file);
$crawler = $this->client->submit($uploadForm);
$mappingForm = $crawler->selectButton('Import')->form();
$crawler = $this->client->submit($mappingForm);
Assert::assertStringContainsString(
'Import process was successfully created. You will be notified when finished.',
$crawler->html(),
$crawler->html()
);
/** @var ImportRepository $importRepository */
$importRepository = $this->em->getRepository(Import::class);
/** @var Import $importEntity */
$importEntity = $importRepository->findOneBy(['originalFile' => 'contacts.csv']);
$importEntity->setStatus(4);
$importRepository->saveEntity($importEntity);
$applicationTester = $this->testSymfonyCommand(ImportCommand::COMMAND_NAME, ['--id' => $importEntity->getId()]);
$this->em->clear();
$expectedString = 'Reason: Import could not be triggered since it is not queued nor delayed';
Assert::assertStringContainsString($expectedString, $applicationTester->getDisplay());
}
public function testImportWithSkipIfExistsFlagTrue(): void
{
$this->createBooleanField();
$lead = $this->createLead('john@doe.email', 'Johny');
$this->createCompanyForLead($lead, 'Company A');
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts/import/new');
$uploadForm = $crawler->selectButton('Upload')->form();
$file = new UploadedFile(dirname(__FILE__).'/../Fixtures/contacts-with-custom-field.csv', 'contacs.csv', 'itext/csv');
$uploadForm['lead_import[file]']->setValue((string) $file);
$crawler = $this->client->submit($uploadForm);
$mappingForm = $crawler->selectButton('Import')->form();
$mappingForm['lead_field_import[skip_if_exists]']->setValue('1');
// fetch company name mapping value
$primaryCompanyOptions = $crawler->filter("#lead_field_import_company > optgroup[label='Primary company']")->filter('option');
$optionValues = $primaryCompanyOptions->each(function ($node) {
if ('Company Name' === $node->text()) {
return $node->attr('value');
}
});
$companyFieldMapping = array_filter($optionValues);
$mappingForm['lead_field_import[company]']->setValue(end($companyFieldMapping));
$crawler = $this->client->submit($mappingForm);
Assert::assertStringContainsString('Import process was successfully created. You will be notified when finished.', $crawler->html(), $crawler->html());
$this->em->clear();
/** @var ImportRepository $importRepository */
$importRepository = $this->em->getRepository(Import::class);
/** @var Import $importEntity */
$importEntity = $importRepository->findOneBy(['originalFile' => 'contacts-with-custom-field.csv']);
$fields = ['email' => 'email', 'firstname' => 'firstname', 'lastname' => 'lastname', 'company' => 'companyname', 'custom_boolean_field' => 'custom_boolean_field'];
Assert::assertNotNull($importEntity);
Assert::assertSame(2, $importEntity->getLineCount());
Assert::assertSame('lead', $importEntity->getObject());
Assert::assertEquals($fields, $importEntity->getProperties()['fields']);
Assert::assertEquals(array_keys($fields), $importEntity->getProperties()['headers']);
$this->testSymfonyCommand(ImportCommand::COMMAND_NAME);
$this->em->clear();
/** @var Import $importEntity */
$importEntity = $importRepository->findOneBy(['originalFile' => 'contacts-with-custom-field.csv']);
Assert::assertNotNull($importEntity);
Assert::assertSame(2, $importEntity->getLineCount());
Assert::assertSame(1, $importEntity->getInsertedCount());
Assert::assertSame(1, $importEntity->getUpdatedCount());
Assert::assertSame(Import::IMPORTED, $importEntity->getStatus());
/** @var LeadRepository $importRepository */
$leadRepository = $this->em->getRepository(Lead::class);
/** @var Lead[] $contacts */
$contacts = $leadRepository->findBy(['email' => ['john@doe.email', 'ferda@mravenec.email']], ['email' => 'desc']);
Assert::assertSame('Johny', $contacts[0]->getFirstname());
Assert::assertSame('Company A', $contacts[0]->getCompany());
Assert::assertSame('Company B', $contacts[1]->getCompany());
Assert::assertCount(2, $contacts);
}
private function setPhoneFieldIsRequired(bool $required): void
{
/** @var LeadFieldRepository $fieldRepository */
$fieldRepository = $this->em->getRepository(LeadField::class);
/** @var LeadField $phoneField */
$phoneField = $fieldRepository->findOneBy(['alias' => 'phone']);
$phoneField->setIsRequired($required);
$fieldRepository->saveEntity($phoneField);
}
private function createLead(?string $email = null, string $firstName = ''): Lead
{
$lead = new Lead();
if (!empty($email)) {
$lead->setEmail($email);
}
$lead->setFirstname($firstName);
$this->em->persist($lead);
return $lead;
}
/**
* @param array<mixed> $permission
*/
private function createRole(bool $isAdmin = false, array $permission = []): Role
{
$role = new Role();
$role->setName('Role');
$role->setIsAdmin($isAdmin);
$role->setRawPermissions($permission);
$this->em->persist($role);
return $role;
}
private function createPermission(string $rawPermission, Role $role, int $bitwise): void
{
$parts = explode(':', $rawPermission);
$permission = new Permission();
$permission->setBundle($parts[0]);
$permission->setName($parts[1]);
$permission->setRole($role);
$permission->setBitwise($bitwise);
$this->em->persist($permission);
}
private function createUser(Role $role): User
{
$user = new User();
$user->setFirstName('John');
$user->setLastName('Doe');
$user->setUsername('john.doe');
$user->setEmail('john.doe@email.com');
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
\assert($hasher instanceof PasswordHasherInterface);
$user->setPassword($hasher->hash('Maut1cR0cks!'));
$user->setRole($role);
$this->em->persist($user);
return $user;
}
private function createBooleanField(): void
{
$user = $this->em->getRepository(User::class)
->findOneBy(['username' => $this->clientServer['PHP_AUTH_USER'] ?? 'admin']);
$this->loginUser($user);
$field = new LeadField();
$field->setType('boolean');
$field->setObject('lead');
$field->setGroup('core');
$field->setLabel('Test boolean field');
$field->setAlias('custom_boolean_field');
$field->setDefaultValue('0');
$field->setProperties(['no' => 'No', 'yes' => 'Yes']);
/** @var FieldModel $fieldModel */
$fieldModel = $this->getContainer()->get('mautic.lead.model.field');
$fieldModel->saveEntity($field);
}
private function createCompanyForLead(Lead $lead, string $companyName): void
{
$company = new Company();
$company->setName($companyName);
$this->em->persist($company);
// add company to lead
$lead->setCompany($companyName);
$this->em->persist($lead);
// set primary company for lead
$companyLead = new CompanyLead();
$companyLead->setCompany($company);
$companyLead->setLead($lead);
$companyLead->setDateAdded(new \DateTime());
$companyLead->setPrimary(true);
$this->em->persist($companyLead);
}
/**
* @return mixed[]
*/
public static function validateDataProvider(): iterable
{
yield ['0', 'John'];
yield ['1', 'Johny'];
}
private function assertInsufficientPermissionError(LeadEventLog $log, User $user): void
{
Assert::assertSame('failed', $log->getAction(), 'The insertion should fail as the user does not have permission to create contacts.');
Assert::assertSame(sprintf('User \'%s\' has insufficient permissions', $user->getUserIdentifier()), $log->getProperties()['error'], 'There should be an insufficient permission error.');
}
private function selectCompanyMapping(Crawler $crawler, Form $mappingForm): void
{
$options = $crawler->filter("#lead_field_import_company > optgroup[label='Primary company']")->filter('option');
$values = array_filter($options->each(function ($node) {
if ('Company Name' === $node->text()) {
return $node->attr('value');
}
}));
$mappingForm['lead_field_import[company]']->setValue(end($values));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
class LeadCompanyControllerTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->configParams['contact_allow_multiple_companies'] = 0;
parent::setUp();
}
public function testSimpleCompanyFeature(): void
{
$crawler = $this->client->request('GET', 's/contacts/new/');
$multiple = $crawler->filterXPath('//*[@id="lead_companies"]')->attr('multiple');
self::assertNull($multiple);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Doctrine\ORM\Exception\ORMException;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
final class LeadControllerListingPageTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->configParams['contact_columns'] = ['name', 'location', 'email'];
parent::setUp();
}
/**
* @param string[] $location
*
* @throws ORMException
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataForContactListing')]
public function testContactListingForLocation(array $location, string $expected): void
{
$this->createContact($location);
$crawler = $this->client->request('GET', 's/contacts');
$rowContent = $crawler->filterXPath("//table[@id='leadTable']//tbody//tr");
Assert::assertStringEndsWith($expected, $rowContent->text());
}
/**
* @return iterable<string, array<int, string|string[]>>
*/
public static function dataForContactListing(): iterable
{
yield 'With no location' => [
// Location Details
[
'setCity' => '',
'setState' => '',
'setCountry' => '',
],
// Expected suffice
'John Doe john@doe.example.com',
];
yield 'With whole location details' => [
// Location Details
[
'setCity' => 'Pune',
'setState' => 'MH',
'setCountry' => 'India',
],
// Expected suffice
'John Doe Pune, MH john@doe.example.com',
];
yield 'With only City for location' => [
// Location Details
[
'setCity' => 'Pune',
'setState' => '',
'setCountry' => '',
],
// Expected suffice
'John Doe Pune john@doe.example.com',
];
}
/**
* @param string[] $location
*
* @throws ORMException
*/
private function createContact(array $location = []): void
{
$contact = new Lead();
$contact->setFirstname('John');
$contact->setLastname('Doe');
$contact->setEmail('john@doe.example.com');
foreach ($location as $name => $value) {
if (empty($value)) {
continue;
}
$contact->$name($value);
}
$this->em->persist($contact);
$this->em->flush();
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Doctrine\DBAL\ArrayParameterType;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadField;
use PHPUnit\Framework\Assert;
class LeadDetailFunctionalTest extends MauticMysqlTestCase
{
public function testCustomFieldOrderIsRespected(): void
{
$lead = new Lead();
$lead->setFirstname('John');
$lead->setLastname('Doe');
$lead->setEmail('john@his-site.com');
$this->em->persist($lead);
$fieldRepository = $this->em->getRepository(LeadField::class);
/** @var LeadField[] $fields */
$fields = $fieldRepository->findBy(['object' => 'lead', 'group' => 'core'], [
'label' => 'desc',
'id' => 'desc',
]);
$order = 0;
// re-order fields by the label
foreach ($fields as $field) {
$field->setOrder(++$order);
$this->em->persist($field);
}
$this->em->flush();
$this->em->clear();
// initialize lead fields to adjust the expected core labels
$lead->setFields([
'core' => [
'First Name' => [
'value' => 'John',
],
'Last Name' => [
'value' => 'Doe',
],
'Email' => [
'value' => 'john@his-site.com',
],
'Primary company' => [
'value' => null,
],
'Points' => [
'value' => 0,
],
],
]);
$leadFields = array_filter($lead->getFields(true), fn ($value) => isset($value['value']));
$leadFields = array_keys($leadFields);
// get expected core labels
$expectedLabels = $this->connection->createQueryBuilder()
->select('label')
->from(MAUTIC_TABLE_PREFIX.'lead_fields')
->where('object = "lead"')
->andWhere('field_group = "core"')
->andWhere('label IN (:leadFields)')
->orderBy('field_order')
->setParameter(
'leadFields',
$leadFields,
ArrayParameterType::STRING
)
->executeQuery()
->fetchFirstColumn();
$expectedLabels = array_merge(['Created on', 'ID'], $expectedLabels);
$crawler = $this->client->request('GET', sprintf('/s/contacts/view/%d', $lead->getId()));
// get actual core labels
$actualLabels = $crawler->filter('#lead-details table')
->first()
->filter('td:first-child')
->extract(['_text']);
$actualLabels = array_map('trim', $actualLabels);
Assert::assertSame($expectedLabels, $actualLabels);
}
public function testLeadViewPreventsXSS(): void
{
$firstName = 'aaa" onmouseover=alert(1) a="';
$lead = new Lead();
$lead->setFirstname($firstName);
$this->em->persist($lead);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', sprintf('/s/contacts/view/%d', $lead->getId()));
$anchorTag = $crawler->filter('#toolbar ul.dropdown-menu-right li')->first()->filter('a');
$mouseOver = $anchorTag->attr('onmouseover');
$dataHeader = $anchorTag->attr('data-header');
Assert::assertNull($mouseOver);
Assert::assertSame(sprintf('Campaigns for %s', $firstName), $dataHeader);
$response = $this->client->getResponse();
// Make sure the data-target-url is not an absolute URL
Assert::assertStringContainsString(sprintf('data-target-url="/s/contacts/view/%s/stats"', $lead->getId()), $response->getContent());
}
public function testLeadDetailPageForSocialTabInDetailsCollapsibleForNoData(): void
{
$contact = new Lead();
$contact->setEmail('john@doe.corp');
$this->em->persist($contact);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', 's/contacts/view/'.$contact->getId());
$data = $crawler->filterXPath('//div[@id="social"]//td');
$this->assertCount(1, $data);
$translator = static::getContainer()->get('translator');
$this->assertStringContainsString($translator->trans('mautic.lead.field.group.no_data'), $data->text());
}
public function testLeadDetailPageForSocialTabInDetailsCollapsible(): void
{
$crawler = $this->client->request('GET', 's/contacts/new/');
$fbLink = 'https://fb.com/john_doe_test';
$form = $crawler->selectButton('Save & Close')->form();
$form->setValues(
[
'lead[firstname]' => 'John',
'lead[lastname]' => 'Doe',
'lead[email]' => 'john@doe.corp',
'lead[facebook]' => $fbLink,
]
);
$crawler = $this->client->submit($form);
$fbProfile = $crawler->filterXPath('//div[@id="social"]//td[2]');
$this->assertCount(1, $fbProfile);
$this->assertStringContainsString($fbLink, $fbProfile->text());
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class LeadListProjectSearchFunctionalTest 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');
$segmentAlpha = $this->createSegment('Segment Alpha');
$segmentBeta = $this->createSegment('Segment Beta');
$this->createSegment('Segment Gamma');
$this->createSegment('Segment Delta');
$segmentAlpha->addProject($projectOne);
$segmentAlpha->addProject($projectTwo);
$segmentBeta->addProject($projectTwo);
$segmentBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/segments', '/s/segments']);
}
/**
* @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' => ['Segment Alpha', 'Segment Beta'],
'unexpectedEntities' => ['Segment Gamma', 'Segment Delta'],
];
yield 'search by one project AND segment name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Segment Beta'],
'unexpectedEntities' => ['Segment Alpha', 'Segment Gamma', 'Segment Delta'],
];
yield 'search by one project OR segment name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Segment Alpha', 'Segment Beta', 'Segment Gamma'],
'unexpectedEntities' => ['Segment Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Segment Gamma', 'Segment Delta'],
'unexpectedEntities' => ['Segment Alpha', 'Segment Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Segment Beta'],
'unexpectedEntities' => ['Segment Alpha', 'Segment Gamma', 'Segment Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Segment Gamma', 'Segment Delta'],
'unexpectedEntities' => ['Segment Alpha', 'Segment Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Segment Alpha', 'Segment Beta'],
'unexpectedEntities' => ['Segment Gamma', 'Segment Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Segment Alpha', 'Segment Gamma', 'Segment Delta'],
'unexpectedEntities' => ['Segment Beta'],
];
}
private function createSegment(string $name): LeadList
{
$leadList = new LeadList();
$leadList->setName($name);
$leadList->setPublicName($name);
$leadList->setAlias(str_replace(' ', '-', mb_strtolower($name)));
$this->em->persist($leadList);
return $leadList;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Doctrine\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector;
use Doctrine\Bundle\DoctrineBundle\Twig\DoctrineExtension;
use Doctrine\ORM\ORMException;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\ListLead;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
final class LeadListSearchFunctionalTest extends MauticMysqlTestCase
{
/**
* @var mixed[]
*/
protected array $clientOptions = ['debug' => true];
/** @noinspection SqlResolve */
public function testSegmentSearch(): void
{
// create some leads
$leadOne = $this->createLead('one');
$leadTwo = $this->createLead('two');
$leadThree = $this->createLead('three');
$leadFour = $this->createLead('four');
$leadFive = $this->createLead('five');
$leadSix = $this->createLead('six');
// add some leads in lists
$listOne = $this->createLeadList('first-list', $leadOne, $leadTwo, $leadThree);
$listTwo = $this->createLeadList('second-list', $leadOne, $leadFour, $leadFive, $leadSix);
$this->em->flush();
$this->em->clear();
$this->client->enableProfiler();
$prefix = static::getContainer()->getParameter('mautic.db_table_prefix');
$previousQueries = [];
// non-existent segment search
$this->assertSearchResult('segment%3AnonExistent', [], [$leadOne, $leadTwo, $leadThree, $leadFour, $leadFive, $leadSix]);
$this->assertQueries([
"SELECT list.id FROM {$prefix}lead_lists list WHERE list.alias = 'nonexistent'",
"SELECT COUNT(l.id) as count FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN (0)))) AND (l.date_identified IS NOT NULL)",
], $previousQueries);
// first-list segment search
$this->assertSearchResult('segment%3A'.$listOne->getAlias(), [$leadOne, $leadTwo, $leadThree], [$leadFour, $leadFive, $leadSix]);
$this->assertQueries([
"SELECT list.id FROM {$prefix}lead_lists list WHERE list.alias = '{$listOne->getAlias()}'",
"SELECT COUNT(l.id) as count FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listOne->getId()}')))) AND (l.date_identified IS NOT NULL)",
"SELECT l.* FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listOne->getId()}')))) AND (l.date_identified IS NOT NULL) ORDER BY l.last_active DESC, l.id DESC LIMIT 30",
], $previousQueries);
$this->assertSearchResult('!segment%3A'.$listOne->getAlias(), [$leadFour, $leadFive, $leadSix], [$leadOne, $leadTwo, $leadThree]);
$this->assertQueries([
"SELECT list.id FROM {$prefix}lead_lists list WHERE list.alias = '{$listOne->getAlias()}'",
"SELECT COUNT(l.id) as count FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (NOT EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listOne->getId()}')))) AND (l.date_identified IS NOT NULL)",
"SELECT l.* FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (NOT EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listOne->getId()}')))) AND (l.date_identified IS NOT NULL) ORDER BY l.last_active DESC, l.id DESC LIMIT 30",
], $previousQueries);
// second-list segment search
$this->assertSearchResult('segment%3A'.$listTwo->getAlias(), [$leadOne, $leadFour, $leadFive, $leadSix], [$leadTwo, $leadThree]);
$this->assertQueries([
"SELECT list.id FROM {$prefix}lead_lists list WHERE list.alias = '{$listTwo->getAlias()}'",
"SELECT COUNT(l.id) as count FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listTwo->getId()}')))) AND (l.date_identified IS NOT NULL)",
"SELECT l.* FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listTwo->getId()}')))) AND (l.date_identified IS NOT NULL) ORDER BY l.last_active DESC, l.id DESC LIMIT 30",
], $previousQueries);
$this->assertSearchResult('!segment%3A'.$listTwo->getAlias(), [$leadTwo, $leadThree], [$leadOne, $leadFour, $leadFive, $leadSix]);
$this->assertQueries([
"SELECT list.id FROM {$prefix}lead_lists list WHERE list.alias = '{$listTwo->getAlias()}'",
"SELECT COUNT(l.id) as count FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (NOT EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listTwo->getId()}')))) AND (l.date_identified IS NOT NULL)",
"SELECT l.* FROM {$prefix}leads l USE INDEX FOR JOIN ({$prefix}lead_date_added) WHERE (NOT EXISTS(SELECT 1 FROM {$prefix}lead_lists_leads lla WHERE (l.id = lla.lead_id) AND (lla.manually_removed = 0) AND (lla.leadlist_id IN ('{$listTwo->getId()}')))) AND (l.date_identified IS NOT NULL) ORDER BY l.last_active DESC, l.id DESC LIMIT 30",
], $previousQueries);
}
/**
* @param Lead[] $expectedLeads
* @param Lead[] $notExpectedLeads
*/
private function assertSearchResult(string $search, array $expectedLeads, array $notExpectedLeads): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/contacts?search='.$search);
self::assertResponseIsSuccessful();
$responseText = $crawler->text();
foreach ($expectedLeads as $expectedLead) {
Assert::assertStringContainsString($expectedLead->getEmail(), $responseText, sprintf('Lead with the email "%s" should be in the result.', $expectedLead->getEmail()));
}
foreach ($notExpectedLeads as $notExpectedLead) {
Assert::assertStringNotContainsString($notExpectedLead->getEmail(), $responseText, sprintf('Lead with the email "%s" should not be in the result.', $notExpectedLead->getEmail()));
}
}
/**
* @param string[] $expectedQueries
* @param string[] $previousQueries
*/
private function assertQueries(array $expectedQueries, array &$previousQueries): void
{
/** @var DoctrineDataCollector $dbCollector */
$dbCollector = $this->client->getProfile()->getCollector('db');
$allQueries = $dbCollector->getQueries()['default'];
$queries = array_diff_key($allQueries, $previousQueries);
$previousQueries = $allQueries;
$doctrineExtension = new DoctrineExtension();
$queries = array_map(function (array $query) use ($doctrineExtension) {
return $doctrineExtension->replaceQueryParameters($query['sql'], $query['params']);
}, $queries);
foreach ($expectedQueries as $expectedQuery) {
$matchedQueries = array_filter($queries, function (string $query) use ($expectedQuery) {
return $expectedQuery === $query;
});
Assert::assertCount(1, $matchedQueries, sprintf('The query "%s" was expected to be executed once.', $expectedQuery));
}
}
/**
* @throws ORMException
*/
private function createLead(string $lastName): Lead
{
$lead = new Lead();
$lead->setLastname($lastName);
$lead->setEmail(sprintf('%s@mail.tld', $lastName));
$this->em->persist($lead);
return $lead;
}
/**
* @param Lead ...$leads
*
* @throws ORMException
*/
private function createLeadList(string $name, ...$leads): LeadList
{
$leadList = new LeadList();
$leadList->setName($name);
$leadList->setPublicName($name);
$leadList->setAlias(mb_strtolower($name));
$this->em->persist($leadList);
foreach ($leads as $lead) {
$this->addLeadToList($lead, $leadList);
}
return $leadList;
}
private function addLeadToList(Lead $leadOne, LeadList $sourceList): void
{
$listLead = new ListLead();
$listLead->setLead($leadOne);
$listLead->setList($sourceList);
$listLead->setDateAdded(new \DateTime());
$this->em->persist($listLead);
}
}

View File

@@ -0,0 +1,817 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Command\SegmentCountCacheCommand;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Helper\SegmentCountCacheHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Model\ListModel;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Model\ProjectModel;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class ListControllerFunctionalTest extends MauticMysqlTestCase
{
private ListModel $listModel;
private LeadListRepository $listRepo;
protected SegmentCountCacheHelper $segmentCountCacheHelper;
private LeadRepository $leadRepo;
protected function setUp(): void
{
$this->configParams['update_segment_contact_count_in_background'] = 'testSegmentCountInBackground' === $this->name();
parent::setUp();
$this->listModel = static::getContainer()->get('mautic.lead.model.list');
\assert($this->listModel instanceof ListModel);
$this->listRepo = $this->listModel->getRepository();
\assert($this->listRepo instanceof LeadListRepository);
$leadModel = static::getContainer()->get('mautic.lead.model.lead');
\assert($leadModel instanceof LeadModel);
$this->segmentCountCacheHelper = static::getContainer()->get('mautic.helper.segment.count.cache');
$this->leadRepo = $leadModel->getRepository();
}
public function testUnpublishUsedSegment(): void
{
$filter = [[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'operator' => '!empty',
'display' => '',
]];
$list1 = $this->saveSegment('s1', 's1', $filter);
$filter = [[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => [
'filter' => [$list1->getId()],
],
'display' => '',
]];
$list2 = $this->saveSegment('s2', 's2', $filter);
$this->em->clear();
$expectedErrorMessage = sprintf('This segment is used in %s, please go back and check segments before unpublishing', $list2->getName());
$crawler = $this->client->request(Request::METHOD_POST, '/s/ajax', ['action' => 'togglePublishStatus', 'model' => 'lead.list', 'id' => $list1->getId()]);
$this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString($expectedErrorMessage, $this->client->getResponse()->getContent());
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$list1->getId());
$this->assertResponseIsSuccessful();
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[isPublished]']->setValue('0');
$crawler = $this->client->submit($form);
$this->assertResponseIsSuccessful();
$this->assertStringContainsString($expectedErrorMessage, $this->client->getResponse()->getContent());
}
public function testUnpublishUnUsedSegment(): void
{
$filter = [[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'operator' => '!empty',
'display' => '',
]];
$list1 = $this->saveSegment('s1', 's1', $filter);
$list2 = $this->saveSegment('s2', 's2', $filter);
$this->em->clear();
$crawler = $this->client->request(Request::METHOD_POST, '/s/ajax', ['action' => 'togglePublishStatus', 'model' => 'lead.list', 'id' => $list1->getId()]);
$this->assertResponseIsSuccessful();
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$list2->getId());
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[isPublished]']->setValue('0');
$crawler = $this->client->submit($form);
$this->assertResponseIsSuccessful();
$rows = $this->listRepo->findAll();
$this->assertCount(2, $rows);
$this->assertFalse($rows[0]->isPublished());
$this->assertFalse($rows[1]->isPublished());
}
public function testBCSegmentWithPageHitInLeadObject(): void
{
$segment = $this->saveSegment(
'Legacy Url Hit segment',
's1',
[
[
'glue' => 'and',
'field' => 'hit_url',
'object' => 'lead',
'type' => 'text',
'filter' => 'unicorn',
'display' => null,
'operator' => '=',
],
]
);
$this->em->clear();
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$segment->getId());
Assert::assertTrue($this->client->getResponse()->isOk());
Assert::assertGreaterThan(0, $crawler->filter('#leadlist_filters_0_operator option')->count());
}
public function testSegmentWithProject(): void
{
$filters = [
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'filter' => null,
'display' => null,
'operator' => '!empty',
],
];
$segment = $this->saveSegment('Segment with Project', 'st1', $filters);
$project = new Project();
$project->setName('Test Project');
$projectModel = self::getContainer()->get(ProjectModel::class);
\assert($projectModel instanceof ProjectModel);
$projectModel->saveEntity($project);
$this->em->clear();
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$segment->getId());
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedSegment = $this->listRepo->find($segment->getId());
Assert::assertSame($project->getId(), $savedSegment->getProjects()->first()->getId());
}
private function saveSegment(string $name, string $alias, array $filters = [], ?LeadList $segment = null): LeadList
{
$segment ??= new LeadList();
$segment->setName($name)->setAlias($alias)->setFilters($filters);
$this->listModel->saveEntity($segment);
return $segment;
}
/**
* @throws \Exception
*/
public function testSegmentCount(): void
{
// Save segment.
$filters = [
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'filter' => null,
'display' => null,
'operator' => '!empty',
],
];
$segment = $this->saveSegment('Lead List 1', 'lead-list-1', $filters);
$segmentId = $segment->getId();
// Save manual segment without filters.
$manualSegment = $this->saveSegment('Lead List 2', 'lead-list-2');
$manualSegmentId = $manualSegment->getId();
// Verify last built date is not set.
self::assertNull($segment->getLastBuiltDate());
// Check segment count UI for no contacts for manual segment.
// And check the filtered segment is Building
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
$spClass = $this->getSegmentCountClass($crawler, $segmentId);
self::assertSame('Building', $html);
self::assertSame('label label-info col-count', $spClass);
$html = $this->getSegmentCountHtml($crawler, $manualSegmentId);
$spClass = $this->getSegmentCountClass($crawler, $manualSegmentId);
self::assertSame('No Contacts', $html);
self::assertSame('label label-gray col-count', $spClass);
// Add 4 contacts.
$contacts = $this->saveContacts();
$contact1Id = $contacts[0]->getId();
// Rebuild segment - set current count to the cache.
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentId, '--env' => 'test']);
// Verify last built date is set.
$this->em->detach($segment);
$segment = $this->listRepo->find($segmentId);
self::assertNotNull($segment->getLastBuiltDate());
// Set last built date in the future to allow testing without waiting.
// (Same second built date as the modified date is shown as "Building" still in the UI).
$segment->setLastBuiltDate(new \DateTime('+5 seconds'));
$this->listModel->saveEntity($segment);
// Check segment count UI for 4 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
$spClass = $this->getSegmentCountClass($crawler, $segmentId);
self::assertSame('View 4 Contacts', $html);
self::assertSame('label label-gray col-count', $spClass);
// Remove 1 contact from segment.
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contact/'.$contact1Id.'/remove');
self::assertSame('{"success":1}', $this->client->getResponse()->getContent());
$this->assertResponseIsSuccessful();
// Check segment count UI for 3 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
$spClass = $this->getSegmentCountClass($crawler, $segmentId);
self::assertSame('View 3 Contacts', $html);
self::assertSame('label label-gray col-count', $spClass);
// Add 1 contact back to segment.
$parameters = ['ids' => [$contact1Id]];
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contacts/add', $parameters);
self::assertSame('{"success":1,"details":{"'.$contact1Id.'":{"success":true}}}', $this->client->getResponse()->getContent());
$this->assertResponseIsSuccessful();
// Check segment count UI for 4 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
$spClass = $this->getSegmentCountClass($crawler, $segmentId);
self::assertSame('View 4 Contacts', $html);
self::assertSame('label label-gray col-count', $spClass);
// Check segment count AJAX for 4 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('View 4 Contacts', $response['content']['html']);
self::assertSame('label label-gray col-count', $response['content']['className']);
self::assertSame(4, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
// Remove 1 contact from segment.
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contact/'.$contact1Id.'/remove');
self::assertSame('{"success":1}', $this->client->getResponse()->getContent());
$this->assertResponseIsSuccessful();
// Check segment count AJAX for 3 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('View 3 Contacts', $response['content']['html']);
self::assertSame('label label-gray col-count', $response['content']['className']);
self::assertSame(3, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
// Add 1 contact back to segment.
$parameters = ['ids' => [$contact1Id]];
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contacts/add', $parameters);
self::assertSame('{"success":1,"details":{"'.$contact1Id.'":{"success":true}}}', $this->client->getResponse()->getContent());
$this->assertResponseIsSuccessful();
// Check segment count AJAX for 4 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('View 4 Contacts', $response['content']['html']);
self::assertSame('label label-gray col-count', $response['content']['className']);
self::assertSame(4, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
// Save filtered segment again to trigger rebuild label, setting last built date in the past.
$this->em->detach($segment);
$segment = $this->listRepo->find($segmentId);
$segment->setLastBuiltDate(new \DateTime('-1 year'));
// Date modified only updates on specific changes, so change name.
$segment->setName('Lead List 1 Updated');
$this->listModel->saveEntity($segment);
// Check segment count UI for bulding with 4 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
$spClass = $this->getSegmentCountClass($crawler, $segmentId);
self::assertSame('Building (4 Contacts)', $html);
self::assertSame('label label-info col-count', $spClass);
// Check segment count AJAX for building 4 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('Building (4 Contacts)', $response['content']['html']);
self::assertSame('label label-info col-count', $response['content']['className']);
self::assertSame(4, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
}
/**
* @throws \Exception
*/
public function testSegmentCountInBackground(): void
{
// Save segment.
$filters = [
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'filter' => null,
'display' => null,
'operator' => '!empty',
],
];
$segment = $this->saveSegment('Lead List 1', 'lead-list-1', $filters);
$segmentId = $segment->getId();
$this->segmentCountCacheHelper->deleteSegmentContactCount($segmentId);
// Check segment count UI for no contacts.
usleep(1000000);
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentId, '--env' => 'test']);
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
self::assertSame('No Contacts', $html);
// Add 4 contacts.
$contacts = $this->saveContacts();
$contact1Id = $contacts[0]->getId();
// Rebuild segment - set current count to the cache.
$this->testSymfonyCommand('mautic:segments:update', ['-i' => $segmentId, '--env' => 'test']);
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment count UI for 4 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
self::assertSame('View 4 Contacts', $html);
// Remove 1 contact from segment.
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contact/'.$contact1Id.'/remove');
self::assertSame('{"success":1}', $this->client->getResponse()->getContent());
self::assertSame(200, $this->client->getResponse()->getStatusCode());
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment count UI for 3 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
self::assertSame('View 3 Contacts', $html);
// Add 1 contact back to segment.
$parameters = ['ids' => [$contact1Id]];
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contacts/add', $parameters);
self::assertSame('{"success":1,"details":{"'.$contact1Id.'":{"success":true}}}', $this->client->getResponse()->getContent());
self::assertSame(200, $this->client->getResponse()->getStatusCode());
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment count UI for 4 contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
self::assertSame('View 4 Contacts', $html);
// Check segment count AJAX for 4 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('View 4 Contacts', $response['content']['html']);
self::assertSame(4, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
// Remove 1 contact from segment.
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contact/'.$contact1Id.'/remove');
self::assertSame('{"success":1}', $this->client->getResponse()->getContent());
$this->assertResponseIsSuccessful();
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment count AJAX for 3 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('View 3 Contacts', $response['content']['html']);
self::assertSame(3, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
// Add 1 contact back to segment.
$parameters = ['ids' => [$contact1Id]];
$this->client->request(Request::METHOD_POST, '/api/segments/'.$segmentId.'/contacts/add', $parameters);
self::assertSame('{"success":1,"details":{"'.$contact1Id.'":{"success":true}}}', $this->client->getResponse()->getContent());
$this->assertResponseIsSuccessful();
$this->testSymfonyCommand(SegmentCountCacheCommand::COMMAND_NAME);
// Check segment count AJAX for 4 contacts.
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('View 4 Contacts', $response['content']['html']);
self::assertSame(4, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
}
public function testSegmentClone(): void
{
$segment = $this->saveSegment('Test Segment', 'testsegment');
$segmentId = $segment->getId();
// Number of segments before clone
$segmentsCountBefore = $this->em->getRepository(LeadList::class)->count([]);
// Go to clone segment action
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/clone/'.(string) $segmentId);
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
// First submit
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$crawler = $this->client->submit($form);
$this->assertEquals(200, $this->client->getResponse()->getStatusCode(), 'Correct Apply');
// Second submit
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$this->client->submit($form);
$this->assertEquals(200, $this->client->getResponse()->getStatusCode(), 'Correct Apply');
// Number of segments after clone
$segmentsCountAfter = $this->em->getRepository(LeadList::class)->count([]);
// Check that just one segment was created
$this->assertSame($segmentsCountBefore + 1, $segmentsCountAfter);
}
public function testSegmentAliasCreation(): void
{
$segment = $this->saveSegment('Test Segment Alias', 'test-segment-alias');
$segmentId = $segment->getId();
// Clone segment
$aliasFirst = $this->getAliasWhenCloneSegment($segmentId);
// Clone segment again
$aliasSecond = $this->getAliasWhenCloneSegment($segmentId);
// Check that aliases are not the same
$this->assertNotSame($aliasFirst, $aliasSecond);
}
private function getAliasWhenCloneSegment(int $segmentId): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/clone/'.(string) $segmentId);
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
// Save cloned segment
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$crawler = $this->client->submit($form);
$this->assertEquals(200, $this->client->getResponse()->getStatusCode(), 'Correct Apply');
return $crawler->filter('#leadlist_alias')->attr('value');
}
public function testSegmentNotFoundOnAjax(): void
{
// Emulate invalid request parameter.
$parameter = ['id' => 'ABC'];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('No Contacts', $response['content']['html']);
self::assertSame(0, $response['content']['leadCount']);
self::assertSame(Response::HTTP_NOT_FOUND, $response['statusCode']);
}
/**
* @return Lead[]
*/
private function saveContacts(int $count = 4): array
{
$contacts = [];
for ($i = 1; $i <= $count; ++$i) {
$contact = new Lead();
$contact->setFirstname('Contact '.$i)->setEmail('contact'.$i.'@example.com');
$contacts[] = $contact;
}
$this->leadRepo->saveEntities($contacts);
return $contacts;
}
private function getSegmentCountHtml(Crawler $crawler, int $id): string
{
$content = $crawler->filter('span.col-count[data-id="'.$id.'"] a')->html();
return trim($content);
}
private function getSegmentCountClass(Crawler $crawler, int $id): string
{
$class = $crawler->filter('span.col-count[data-id="'.$id.'"]')->attr('class');
return trim($class);
}
/**
* @param array<string, mixed> $parameter
*
* @return array<string, mixed>
*/
private function callGetLeadCountAjaxRequest(array $parameter): array
{
$this->client->request(Request::METHOD_POST, '/s/ajax?action=lead:getLeadCount', $parameter);
$clientResponse = $this->client->getResponse();
return [
'content' => json_decode($clientResponse->getContent(), true),
'statusCode' => $this->client->getResponse()->getStatusCode(),
];
}
public function testCloneSegment(): void
{
$segment = $this->saveSegment(
'Clone Segment',
'clonesegment',
);
$this->em->clear();
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/clone/'.$segment->getId());
$this->assertResponseIsSuccessful();
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[alias]']->setValue('clonesegment2');
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$this->client->submit($form);
$rows = $this->listRepo->findAll();
$this->assertCount(2, $rows);
$this->assertSame('clonesegment', $rows[0]->getAlias());
$this->assertSame('clonesegment2', $rows[1]->getAlias());
}
public function testSegmentFilterIcon(): void
{
// Save segment.
$filters = [
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'filter' => null,
'display' => null,
'operator' => '!empty',
],
];
$this->saveSegment('Lead List 1', 'lead-list-1', $filters);
$this->saveSegment('Lead List 2', 'lead-list-2');
// Check segment count UI for no contacts.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$leadListsTableRows = $crawler->filterXPath("//table[@id='leadListTable']//tbody//tr");
$this->assertEquals(2, $leadListsTableRows->count());
$secondColumnOfLine = $leadListsTableRows->first()->filterXPath('//td[2]//div//i[@class="ri-fw ri-filter-2-fill fs-14"]')->count();
$this->assertEquals(1, $secondColumnOfLine);
$secondColumnOfLine = $leadListsTableRows->eq(1)->filterXPath('//td[2]//div//i[@class="ri-fw ri-filter-2-fill fs-14"]')->count();
$this->assertEquals(0, $secondColumnOfLine);
}
public function testUnpublishedSegmentDoesNotShowRebuildingLabel(): void
{
// Create a segment that would normally show "Building" label
$segment = $this->saveSegment('Unpublished Segment', 'unpublished-segment', [
[
'glue' => 'and',
'field' => 'email',
'object' => 'lead',
'type' => 'email',
'operator' => '!empty',
'display' => '',
],
]);
// Set last built date in the past to trigger "Building" label for published segments
$segment->setLastBuiltDate(new \DateTime('-1 year'));
// Unpublish the segment - this should prevent "Building" label
$segment->setIsPublished(false);
$this->listModel->saveEntity($segment);
$this->em->clear();
$segmentId = $segment->getId();
// Check segment count UI - should show "No Contacts" rather than "Building"
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$html = $this->getSegmentCountHtml($crawler, $segmentId);
$spClass = $this->getSegmentCountClass($crawler, $segmentId);
self::assertSame('No Contacts', $html);
self::assertSame('label label-gray col-count', $spClass);
// Check segment count AJAX - should also show "No Contacts"
$parameter = ['id' => $segmentId];
$response = $this->callGetLeadCountAjaxRequest($parameter);
self::assertSame('No Contacts', $response['content']['html']);
self::assertSame('label label-gray col-count', $response['content']['className']);
self::assertSame(0, $response['content']['leadCount']);
self::assertSame(Response::HTTP_OK, $response['statusCode']);
}
public function testSegmentWarningIcon(): void
{
$segmentWithOldLastRebuildDate = $this->saveSegment('TEST-Warning-Segment', 'test-warning-segment');
$segmentWithFreshLastRebuildDate = $this->saveSegment('TEST-Fresh-Segment', 'test-fresh-segment');
$segmentUnpublished = $this->saveSegment('TEST-Unpublished-Segment', 'test-unpublished-segment');
$segmentWithOldLastRebuildDate->setLastBuiltDate(new \DateTime('-1 year'));
$segmentWithFreshLastRebuildDate->setLastBuiltDate(new \DateTime('now'));
$segmentUnpublished->setIsPublished(false);
$this->em->persist($segmentWithOldLastRebuildDate);
$this->em->persist($segmentWithFreshLastRebuildDate);
$this->em->persist($segmentUnpublished);
$this->em->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$warningSegmentRow = $crawler->filterXPath("//table[@id='leadListTable']//tbody//tr[contains(., 'TEST-Warning-Segment')]");
$warningIcon = $warningSegmentRow->filterXPath('.//i[@class="text-danger ri-error-warning-line fs-14"]');
$this->assertEquals(1, $warningIcon->count());
$freshSegmentRow = $crawler->filterXPath("//table[@id='leadListTable']//tbody//tr[contains(., 'TEST-Fresh-Segment')]");
$warningIcon = $freshSegmentRow->filterXPath('.//i[@class="text-danger ri-error-warning-line fs-14"]');
$this->assertEquals(0, $warningIcon->count());
$unpublishedSegmentRow = $crawler->filterXPath("//table[@id='leadListTable']//tbody//tr[contains(., 'TEST-Unpublished-Segment')]");
$warningIcon = $unpublishedSegmentRow->filterXPath('.//i[@class="text-danger ri-error-warning-line fs-14"]');
$this->assertEquals(0, $warningIcon->count());
}
public function testBatchDeleteWithEmptyMembership(): void
{
$segment = $this->saveSegment(
'Empty Members',
'empty-members',
[
[
'glue' => 'and',
'field' => 'leadlist',
'object' => 'lead',
'type' => 'leadlist',
'filter' => null,
'display' => null,
'operator' => 'empty',
],
]
);
$segmentId = $segment->getId();
$this->setCsrfHeader();
$this->client->xmlHttpRequest('POST', "s/segments/batchDelete?ids=[\"{$segmentId}\"]");
$clientResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode(), $clientResponse->getContent());
$this->assertStringContainsString('1 segments have been deleted!', $clientResponse->getContent());
$this->em->clear();
$segmentExistCheck = $this->listRepo->find($segmentId);
Assert::assertNull($segmentExistCheck);
}
#[\PHPUnit\Framework\Attributes\DataProvider('dateFieldProvider')]
public function testWarningOnInvalidDateField(?string $filter, bool $shouldContainError, string $operator = '='): void
{
$segment = $this->saveSegment(
'Date Segment',
'ds',
[
[
'glue' => 'and',
'field' => 'date_added',
'object' => 'lead',
'type' => 'date',
'filter' => $filter,
'display' => null,
'operator' => $operator,
],
]
);
$this->em->clear();
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$segment->getId());
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$this->client->submit($form);
$this->assertTrue($this->client->getResponse()->isOk());
if ($shouldContainError) {
$this->assertStringContainsString('Date field filter value &quot;'.$filter.'&quot; is invalid', $this->client->getResponse()->getContent());
} else {
$this->assertStringNotContainsString('Date field filter value', $this->client->getResponse()->getContent());
}
}
/**
* @return array<int, array<int, bool|string|null>>
*/
public static function dateFieldProvider(): array
{
return [
['Today', true],
['birthday', false],
['2023-01-01 11:00', false],
['2023-01-01 11:00:00', false],
['2023-01-01', false],
['next week', false],
[null, false],
['\b\d{4}-(10|11|12)-\d{2}\b', false, 'regexp'],
];
}
public function testRecentActivityFeedOnSegmentDetailsPage(): void
{
// Create segment
$segment = $this->saveSegment('Date Segment', 'ds');
$this->em->clear();
// Update segment
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$segment->getId());
$this->assertResponseIsSuccessful();
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[isPublished]']->setValue('0');
$this->client->submit($form);
// View segment
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/view/'.$segment->getId());
$this->assertResponseIsSuccessful();
$translator = self::getContainer()->get('translator');
$this->assertStringContainsString($translator->trans('mautic.core.recent.activity'), $this->client->getResponse()->getContent());
$this->assertCount(2, $crawler->filterXPath('//ul[contains(@class, "media-list-feed")]/li'));
}
public function testActiveContactsStatExcludesDnc(): void
{
$segment = $this->saveSegment('active-test', 'active-test');
$contact1 = new Lead();
$contact1->setFirstname('Active');
$this->em->persist($contact1);
$contact2 = new Lead();
$contact2->setFirstname('DNC');
$this->em->persist($contact2);
$this->em->flush();
$segmentContact1 = new \Mautic\LeadBundle\Entity\ListLead();
$segmentContact1->setList($segment);
$segmentContact1->setLead($contact1);
$segmentContact1->setDateAdded(new \DateTime());
$segmentContact1->setManuallyAdded(false);
$segmentContact1->setManuallyRemoved(false);
$this->em->persist($segmentContact1);
$segmentContact2 = new \Mautic\LeadBundle\Entity\ListLead();
$segmentContact2->setList($segment);
$segmentContact2->setLead($contact2);
$segmentContact2->setDateAdded(new \DateTime());
$segmentContact2->setManuallyAdded(false);
$segmentContact2->setManuallyRemoved(false);
$this->em->persist($segmentContact2);
$this->em->flush();
$dnc = new \Mautic\LeadBundle\Entity\DoNotContact();
$dnc->setChannel('email');
$dnc->setLead($contact2);
$dnc->setReason(\Mautic\LeadBundle\Entity\DoNotContact::UNSUBSCRIBED);
$dnc->setDateAdded(new \DateTime());
$this->em->persist($dnc);
$this->em->flush();
$this->client->request('GET', sprintf('/s/segments/view/%d', $segment->getId()));
$response = $this->client->getResponse();
$this->assertResponseIsSuccessful();
$html = $response->getContent();
$this->assertStringContainsString('Total contacts', $html);
$this->assertStringContainsString('2', $html);
$this->assertStringContainsString('Active contacts', $html);
$this->assertStringContainsString('1', $html);
}
}

View File

@@ -0,0 +1,755 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\UserBundle\Entity\Permission;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
final class ListControllerPermissionFunctionalTest extends MauticMysqlTestCase
{
/**
* @var User
*/
private $nonAdminUser;
/**
* @var User
*/
private $userOne;
/**
* @var User
*/
private $userTwo;
/**
* @var LeadList
*/
private $segmentA;
protected function setUp(): void
{
parent::setUp();
$this->nonAdminUser = $this->createUser([
'user-name' => 'non-admin',
'email' => 'non-admin@mautic-test.com',
'first-name' => 'non-admin',
'last-name' => 'non-admin',
'role' => [
'name' => 'perm_non_admin',
'perm' => 'core:themes',
'bitwise' => 1024,
],
]);
$this->userOne = $this->createUser(
[
'user-name' => 'user-one',
'email' => 'user-one@mautic-test.com',
'first-name' => 'user-one',
'last-name' => 'user-one',
'role' => [
'name' => 'perm_user_one',
'perm' => 'lead:lists',
'bitwise' => 40,
],
]
);
$this->userTwo = $this->createUser([
'user-name' => 'user-two',
'email' => 'user-two@mautic-test.com',
'first-name' => 'user-two',
'last-name' => 'user-two',
'role' => [
'name' => 'perm_user_two',
'perm' => 'lead:lists',
'bitwise' => 16,
],
]);
$this->segmentA = $this->createSegment('Segment List A', $this->userOne);
}
public function testIndexPageWithCreatePermission(): void
{
$this->loginOtherUser($this->userOne->getUserIdentifier());
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertCount(1, $crawler->filterXPath('//a[contains(@href,"/s/segments/new")]'), 'Listing page has the New button');
}
public function testIndexPageNonAdmin(): void
{
$this->loginOtherUser($this->nonAdminUser->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments');
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testIndexPageForPaging(): void
{
$this->client->request(Request::METHOD_GET, '/s/segments/2');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testCreateSegmentForUserWithoutPermission(): void
{
$this->loginOtherUser($this->nonAdminUser->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/new');
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testCreateSegmentForUserWithPermission(): void
{
$this->loginOtherUser($this->userOne->getUserIdentifier());
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/new');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// Submitting for cancel button click.
$form = $crawler->selectButton('Cancel')->form();
$crawlerCancel = $this->client->submit($form);
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('Contact Segments', $crawlerCancel->html());
// Save the Segment.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/new');
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[name]']->setValue('Segment Test');
$form['leadlist[alias]']->setValue('segment_test');
$form['leadlist[isPublished]']->setValue('0');
$crawler = $this->client->submit($form);
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('Edit Segment - Segment Test', $crawler->html());
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataSegmentCloneUserPermissions')]
public function testSegmentCloningOwnedSegmentWithDifferentPermissions(string $name, int $perm, int $expected): void
{
$user = $this->createUser(
[
'user-name' => $name,
'email' => $name.'@mautic-test.com',
'first-name' => $name,
'last-name' => $name,
'role' => [
'name' => 'perm_user_three',
'perm' => 'lead:lists',
'bitwise' => $perm, // Create and View own
],
]
);
$this->loginOtherUser($user->getUserIdentifier());
$segment = $this->createSegment('Test Segment for clone test', $user);
$this->client->request(Request::METHOD_GET, '/s/segments/clone/'.$segment->getId());
$this->assertEquals($expected, $this->client->getResponse()->getStatusCode());
}
/**
* @return iterable<string, mixed[]>
*/
public static function dataSegmentCloneUserPermissions(): iterable
{
yield 'Only create' => ['user-clone-1', 32, Response::HTTP_FORBIDDEN];
yield 'Create and View own' => ['user-clone-2', 34, Response::HTTP_OK];
yield 'Create and View other' => ['user-clone-2', 36, Response::HTTP_FORBIDDEN];
}
public function testSegmentCloningUsingUserHavingPermissions(): void
{
$user = $this->createUser(
[
'user-name' => 'user-3',
'email' => 'user-3@mautic-test.com',
'first-name' => 'user-3',
'last-name' => 'user-3',
'role' => [
'name' => 'perm_user_three',
'perm' => 'lead:lists',
'bitwise' => 36, // Create and view other
],
]
);
$this->loginOtherUser($user->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/clone/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testSegmentCloningUsingUserWithoutPermissions(): void
{
$this->loginOtherUser($this->userTwo->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/clone/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testCloneInvalidSegment(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/clone/2000');
// For no entity found it will redirect to index page.
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('/s/segments/1', $this->client->getRequest()->getRequestUri());
$this->assertStringContainsString('No segment with an id of 2000 was found!', $crawler->text());
}
public function testEditSegmentAndClickOnButtons(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// Submitting for cancel button click.
$form = $crawler->selectButton('Cancel')->form();
$crawlerCancel = $this->client->submit($form);
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString($this->segmentA->getName(), $crawlerCancel->html());
// Save the Segment.
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$this->segmentA->getId());
$form = $crawler->selectButton('leadlist_buttons_apply')->form();
$form['leadlist[isPublished]']->setValue('0');
$crawler = $this->client->submit($form);
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('Edit Segment - '.$this->segmentA->getName(), $crawler->html());
}
public function testEditInvalidSegment(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/segments/edit/2000');
// For no entity found it will redirect to index page.
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('/s/segments/1', $this->client->getRequest()->getRequestUri());
$this->assertStringContainsString('No segment with an id of 2000 was found!', $crawler->text());
}
public function testEditOwnSegment(): void
{
$this->loginOtherUser($this->userOne->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testEditOthersSegment(): void
{
$this->loginOtherUser($this->userTwo->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testEditSegmentForUserWithoutPermission(): void
{
$user = $this->createUser([
'user-name' => 'user-edit',
'email' => 'user-edit@mautic-test.com',
'first-name' => 'user-edit',
'last-name' => 'user-edit',
'role' => [
'name' => 'perm_user_edit',
'perm' => 'lead:lists',
'bitwise' => 8,
],
]);
$this->loginOtherUser($user->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testEditSegmentWhileLock(): void
{
$segmentA = $this->segmentA;
$segmentA->setCheckedOut(new \DateTime());
$segmentA->setCheckedOutBy($this->userOne);
$this->em->persist($segmentA);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/segments/edit/'.$segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// As $segmentA is locked, so it will redirect user to its view page.
$this->assertStringContainsString('/s/segments/view/'.$segmentA->getId(), $this->client->getRequest()->getRequestUri());
}
public function testDeleteSegmentWithoutPermission(): void
{
$this->loginOtherUser($this->nonAdminUser->getUserIdentifier());
$this->client->request(Request::METHOD_POST, '/s/segments/delete/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testDeleteOthersSegmentWithPermission(): void
{
$user = $this->createUser([
'user-name' => 'user-delete-other',
'email' => 'user-delete-other@mautic-test.com',
'first-name' => 'user-delete-other',
'last-name' => 'user-delete-other',
'role' => [
'name' => 'perm_user_delete_other',
'perm' => 'lead:lists',
'bitwise' => 128,
],
]);
$this->loginOtherUser($user->getUserIdentifier());
$this->client->request(Request::METHOD_POST, '/s/segments/delete/'.$this->segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testDeleteSegmentWithDependencyAndLockedInWithOtherUser(): void
{
$listId = $this->segmentA->getId();
$filter = [[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => [
'filter' => [$listId],
],
'display' => '',
'filter' => [$listId],
]];
$segmentA = $this->createSegment('Segment List A', $this->userTwo, $filter);
$this->assertSame($filter, $segmentA->getFilters(), 'Filters');
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/delete/'.$listId);
$this->assertStringContainsString("Segment cannot be deleted, it is required by {$segmentA->getName()}.", $crawler->text());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$segmentA->setCheckedOut(new \DateTime());
$segmentA->setCheckedOutBy($this->userOne);
$this->em->persist($segmentA);
$this->em->flush();
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/delete/'.$segmentA->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString("{$segmentA->getName()} is currently checked out by", $crawler->html());
// As $segmentA is locked, so it will redirect user to its view page.
$this->assertStringContainsString('/s/segments/1', $this->client->getRequest()->getRequestUri());
}
public function testDeleteInvalidSegment(): void
{
$listId = 99999;
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/delete/'.$listId);
$this->assertStringContainsString("No segment with an id of {$listId} was found!", $crawler->html());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testBatchDeleteSegmentWhenUserDoNotHavePermission(): void
{
$user = $this->createUser([
'user-name' => 'user-delete-a',
'email' => 'user-delete-a@mautic-test.com',
'first-name' => 'user-delete-a',
'last-name' => 'user-delete-a',
'role' => [
'name' => 'perm_user_delete_a',
'perm' => 'lead:lists',
'bitwise' => 82,
],
]);
$this->loginOtherUser($user->getUserIdentifier());
$segmentIds = [
$this->segmentA->getId(),
];
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/batchDelete?ids='.json_encode($segmentIds));
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// The logged-in user do not have permission to delete the segment $this->segmentA.
$this->assertStringContainsString('You do not have access to the requested area/action.', $crawler->text());
}
public function testBatchDeleteSegmentWhenUserDoNotHavePermissionAndSegmentIsInvalid(): void
{
$user = $this->createUser([
'user-name' => 'user-delete-a',
'email' => 'user-delete-a@mautic-test.com',
'first-name' => 'user-delete-a',
'last-name' => 'user-delete-a',
'role' => [
'name' => 'perm_user_delete_a',
'perm' => 'lead:lists',
'bitwise' => 82,
],
]);
$this->loginOtherUser($user->getUserIdentifier());
$segmentIds = [
101,
];
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/batchDelete?ids='.json_encode($segmentIds));
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// The segment 101 is invalid.
$this->assertStringContainsString('No segment with an id of 101 was found!', $crawler->text());
}
public function testBatchDeleteSegmentWhenUserHavePermission(): void
{
$user = $this->createUser([
'user-name' => 'user-delete-a',
'email' => 'user-delete-a@mautic-test.com',
'first-name' => 'user-delete-a',
'last-name' => 'user-delete-a',
'role' => [
'name' => 'perm_user_delete_a',
'perm' => 'lead:lists',
'bitwise' => 82,
],
]);
$segmentA = $this->createSegment('Segment List A', $user);
$this->em->flush();
$this->loginOtherUser($user->getUserIdentifier());
$segmentIds = [
$segmentA->getId(),
];
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/batchDelete?ids='.json_encode($segmentIds));
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// Only one segments is deleted.
$this->assertStringContainsString('1 segments have been deleted!', $crawler->html());
}
public function testBatchDeleteSegmentWhenDeletingLocked(): void
{
$user = $this->createUser([
'user-name' => 'user-delete-a',
'email' => 'user-delete-a@mautic-test.com',
'first-name' => 'user-delete-a',
'last-name' => 'user-delete-a',
'role' => [
'name' => 'perm_user_delete_a',
'perm' => 'lead:lists',
'bitwise' => 82,
],
]);
$segmentC = $this->createSegment('Segment List C', $user);
$segmentC->setCheckedOut(new \DateTime());
$segmentC->setCheckedOutBy($this->userOne);
$this->em->persist($segmentC);
$this->em->flush();
$this->loginOtherUser($user->getUserIdentifier());
$segmentIds = [
$segmentC->getId(),
];
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/batchDelete?ids='.json_encode($segmentIds));
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// The segment $segmentC is being locked by user other than logged-in.
$this->assertStringContainsString("{$segmentC->getName()} is currently checked out by", $crawler->html());
}
public function testBatchDeleteSegmentWhenDeletingRequiredByOthers(): void
{
$user = $this->createUser([
'user-name' => 'user-delete-a',
'email' => 'user-delete-a@mautic-test.com',
'first-name' => 'user-delete-a',
'last-name' => 'user-delete-a',
'role' => [
'name' => 'perm_user_delete_a',
'perm' => 'lead:lists',
'bitwise' => 82,
],
]);
$segmentA = $this->createSegment('Segment List A', $user);
$filter = [[
'object' => 'lead',
'glue' => 'and',
'field' => 'leadlist',
'type' => 'leadlist',
'operator' => 'in',
'properties' => [
'filter' => [$segmentA->getId()],
],
'display' => '',
'filter' => [$segmentA->getId()],
]];
$segmentB = $this->createSegment('Segment List with filter', $user, $filter);
$this->assertSame($filter, $segmentB->getFilters(), 'Filters');
$this->loginOtherUser($user->getUserIdentifier());
$segmentIds = [
$segmentA->getId(),
];
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/batchDelete?ids='.json_encode($segmentIds));
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
// The segment $segmentA is used as filter in $segmentB.
$this->assertStringContainsString("{$segmentA->getName()} cannot be deleted, it is required by other segments.", $crawler->text());
}
public function testViewSegment(): void
{
$user = $this->createUser([
'user-name' => 'user-view-own',
'email' => 'user-view-own@mautic-test.com',
'first-name' => 'user-view-own',
'last-name' => 'user-view-own',
'role' => [
'name' => 'perm_user_view_own',
'perm' => 'lead:lists',
'bitwise' => 2,
],
]);
$segment = $this->createSegment('Segment News View', $user);
$this->loginOtherUser($user->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/view/'.$segment->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->loginOtherUser($this->userOne->getUserIdentifier());
$this->client->request(Request::METHOD_GET, '/s/segments/view/'.$segment->getId());
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testPostOnViewSegment(): void
{
$this->client->request(Request::METHOD_POST, '/s/segments/view/'.$this->segmentA->getId(), [
'includeEvents' => [
'manually_added',
'manually_removed',
'filter_added',
],
]);
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testRemoveLeadFromSegmentWhereUserIsNotOwnerOfSegment(): void
{
$leadId = $this->createLead($this->userOne)->getId();
$this->loginOtherUser($this->userTwo->getUserIdentifier());
$this->client->request(Request::METHOD_POST, '/s/segments/removeLead/'.$this->segmentA->getId().'?leadId='.$leadId);
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testRemoveLeadFromSegmentWhereUserIsOwnerOfSegment(): void
{
$leadId = $this->createLead($this->userOne)->getId();
$this->loginOtherUser($this->userOne->getUserIdentifier());
$this->client->request(Request::METHOD_POST, '/s/segments/removeLead/'.$this->segmentA->getId().'?leadId='.$leadId);
$this->assertEquals(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testAddLeadToSegmentForInvalidLeadAndLockedLeadAndInvalidSegment(): void
{
$leadId = 99999;
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/addLead/'.$this->segmentA->getId().'?leadId='.$leadId);
$this->assertStringContainsString("No contact with an id of {$leadId} was found!", $crawler->html());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$listId = 9999;
$lead = $this->createLead($this->userOne);
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/addLead/'.$listId.'?leadId='.$lead->getId());
$this->assertStringContainsString("No segment with an id of {$listId} was found!", $crawler->html());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$lead->setCheckedOut(new \DateTime());
$lead->setCheckedOutBy($this->userOne);
$this->em->persist($lead);
$this->em->flush();
$crawler = $this->client->request(Request::METHOD_POST, '/s/segments/addLead/'.$this->segmentA->getId().'?leadId='.$lead->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString("{$lead->getPrimaryIdentifier()} is currently checked out by", $crawler->html());
}
public function testUserCanPublishOwnSegmentWithPermission(): void
{
$userDetails = [
'user-name' => 'testuser_publish',
'first-name' => 'Test',
'last-name' => 'Publisher',
'email' => 'testuser_publish@example.com',
'role' => [
'name' => 'Can Publish Own Segments',
'perm' => 'lead:lists',
'bitwise' => 382, // View own&other, Edit own&other, Create, Delete One, Publish Own
],
];
$user = $this->createUser($userDetails);
$this->loginOtherUser($user->getUserIdentifier());
$segment = $this->createSegment('My Segment to Publish', $user);
$this->assertTrue($segment->isPublished());
$this->client->request(
'POST',
'/s/ajax',
[
'action' => 'togglePublishStatus',
'model' => 'lead.list',
'id' => $segment->getId(),
]
);
$this->assertEquals(200, $this->client->getResponse()->getStatusCode());
$this->em->refresh($segment);
$this->assertFalse($segment->isPublished());
}
public function testUserCannotPublishOwnSegmentWithoutPermission(): void
{
$userDetails = [
'user-name' => 'testuser_nopublish',
'first-name' => 'Test',
'last-name' => 'NoPublisher',
'email' => 'testuser_nopublish@example.com',
'role' => [
'name' => 'No Publish Permission',
'perm' => 'lead:lists',
'bitwise' => 126, // View own&other, Edit own&other, Create, Delete One
],
];
$user = $this->createUser($userDetails);
$this->loginOtherUser($user->getUserIdentifier());
$segment = $this->createSegment('Segment Without Publish', $user);
$this->client->request(
'POST',
'/s/ajax',
[
'action' => 'togglePublishStatus',
'model' => 'lead.list',
'id' => $segment->getId(),
]
);
$this->assertEquals(403, $this->client->getResponse()->getStatusCode());
$this->em->refresh($segment);
$this->assertTrue($segment->isPublished());
}
private function loginOtherUser(string $name): void
{
$this->client->request(Request::METHOD_GET, '/s/logout');
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $name]);
$this->loginUser($user);
$this->client->setServerParameter('PHP_AUTH_USER', $name);
$this->client->setServerParameter('PHP_AUTH_PW', 'Maut1cR0cks!');
}
/**
* @param array<string, mixed> $userDetails
*/
private function createUser(array $userDetails): User
{
$role = new Role();
$role->setName($userDetails['role']['name']);
$role->setIsAdmin(false);
$this->em->persist($role);
$this->createPermission($role, $userDetails['role']['perm'], $userDetails['role']['bitwise']);
$user = new User();
$user->setEmail($userDetails['email']);
$user->setUsername($userDetails['user-name']);
$user->setFirstName($userDetails['first-name']);
$user->setLastName($userDetails['last-name']);
$user->setRole($role);
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
\assert($hasher instanceof PasswordHasherInterface);
$user->setPassword($hasher->hash('Maut1cR0cks!'));
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function createPermission(Role $role, string $rawPermission, int $bitwise): void
{
$parts = explode(':', $rawPermission);
$permission = new Permission();
$permission->setBundle($parts[0]);
$permission->setName($parts[1]);
$permission->setRole($role);
$permission->setBitwise($bitwise);
$this->em->persist($permission);
}
/**
* @param mixed[] $filters
*/
private function createSegment(string $name, User $user, array $filters = []): LeadList
{
$segment = new LeadList();
$segment->setName($name);
$segment->setPublicName($name);
$segment->setAlias(str_shuffle('abcdefghijklmnopqrstuvwxyz'));
$segment->setCreatedBy($user);
if ($filters) {
$segment->setFilters($filters);
}
$this->em->persist($segment);
$this->em->flush();
return $segment;
}
private function createLead(User $user): Lead
{
$lead = new Lead();
$lead->setCreatedByUser($user);
$this->em->persist($lead);
$this->em->flush();
return $lead;
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Traits\ControllerTrait;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Entity\ListLead;
class ListControllerTest extends MauticMysqlTestCase
{
use ControllerTrait;
/**
* Index action should return status code 200.
*/
public function testIndexAction(): void
{
$list = $this->createList();
$this->em->persist($list);
$this->em->flush();
$this->em->clear();
$urlAlias = 'segments';
$routeAlias = 'leadlist';
$column = 'dateModified';
$column2 = 'name';
$tableAlias = 'l.';
$this->getControllerColumnTests($urlAlias, $routeAlias, $column, $tableAlias, $column2);
}
/**
* Check if list contains correct values.
*/
public function testViewList(): void
{
$list = $this->createList();
$list->setDateAdded(new \DateTime('2020-02-07 20:29:02'));
$list->setDateModified(new \DateTime('2020-03-21 20:29:02'));
$list->setCreatedByUser('Test User');
$this->em->persist($list);
$this->em->flush();
$this->em->clear();
$this->client->request('GET', '/s/segments');
$clientResponse = $this->client->getResponse();
$this->assertResponseIsSuccessful('Return code must be 200.');
$this->assertStringContainsString('February 7, 2020', $clientResponse->getContent());
$this->assertStringContainsString('March 21, 2020', $clientResponse->getContent());
$this->assertStringContainsString('Test User', $clientResponse->getContent());
}
/**
* Filtering should return status code 200.
*/
public function testIndexActionWhenFiltering(): void
{
$this->client->request('GET', '/s/segments?search=has%3Aresults&tmpl=list');
$clientResponse = $this->client->getResponse();
$this->assertResponseIsSuccessful('Return code must be 200.');
}
public function testSegmentView(): void
{
$contacts = $this->createContacts();
$segment = $this->addContactsToSegment($contacts, 'MySeg');
$this->client->request('GET', sprintf('/s/segments/view/%d', $segment->getId()));
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
self::assertStringContainsString('MySeg', $response->getContent());
// Make sure that contact grid is not loaded synchronously
self::assertStringNotContainsString('Kane', $response->getContent());
self::assertStringNotContainsString('Jacques', $response->getContent());
// Make sure the data-target-url is not an absolute URL
self::assertStringContainsString(sprintf('data-target-url="/s/segment/view/%s/contact/1"', $segment->getId()), $response->getContent());
}
public function testSegmentContactGrid(): void
{
$pageId = 1;
$contacts = $this->createContacts();
$segment = $this->addContactsToSegment($contacts, 'MySeg');
$this->client->request('GET', sprintf('/s/segment/view/%d/contact/%d', $segment->getId(), $pageId));
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
self::assertStringContainsString('Kane', $response->getContent());
self::assertStringContainsString('Jacques', $response->getContent());
}
private function createList(string $suffix = 'A'): LeadList
{
$list = new LeadList();
$list->setName("Segment $suffix");
$list->setPublicName("Segment $suffix");
$list->setAlias("segment-$suffix");
$list->setDateAdded(new \DateTime('2020-02-07 20:29:02'));
$list->setDateModified(new \DateTime('2020-03-21 20:29:02'));
$list->setCreatedByUser('Test User');
return $list;
}
/**
* @return Lead[]
*/
private function createContacts(): array
{
$contact1 = new Lead();
$contact1->setFirstname('Kane');
$contact1->setLastname('Williamson');
$contact1->setEmail('kane.williamson@test.com');
$contact2 = new Lead();
$contact2->setFirstname('Jacques');
$contact2->setLastname('Kallis');
$contact2->setEmail('jacques.kallis@test.com');
$this->em->persist($contact1);
$this->em->persist($contact2);
$this->em->flush();
return [$contact1, $contact2];
}
/**
* @param Lead[] $contacts
*/
private function addContactsToSegment(array $contacts, string $segmentName): LeadList
{
$filters = [
[
'glue' => 'and',
'field' => 'company',
'object' => 'lead',
'type' => 'text',
'operator' => 'contains',
'properties' => [
'filter' => 'Acquia',
],
'filter' => 'Acquia',
'display' => null,
],
];
$segment = new LeadList();
$segment->setName($segmentName);
$segment->setPublicName($segmentName);
$segment->setAlias(strtolower($segmentName));
$segment->isPublished(true);
$segment->setDateAdded(new \DateTime());
$segment->setFilters($filters);
$segment->setIsGlobal(true);
$segment->setIsPreferenceCenter(false);
$this->em->persist($segment);
foreach ($contacts as $contact) {
$segmentContacts = new ListLead();
$segmentContacts->setList($segment);
$segmentContacts->setLead($contact);
$segmentContacts->setDateAdded(new \DateTime());
$segmentContacts->setManuallyAdded(false);
$segmentContacts->setManuallyRemoved(false);
$this->em->persist($segmentContacts);
}
$this->em->flush();
return $segment;
}
public function testCloneSegmentPage(): void
{
$list = $this->createList('clone');
$list->setDateAdded(new \DateTime('2020-02-07 20:29:02'));
$list->setDateModified(new \DateTime('2020-03-21 20:29:02'));
$list->setCreatedByUser('Test User');
$this->em->persist($list);
$this->em->flush();
$this->em->clear();
$this->client->request('GET', sprintf('/s/segments/clone/%d', $list->getId()));
$clientResponse = $this->client->getResponse();
$this->assertResponseIsSuccessful('Return code must be 200.');
self::assertStringContainsString('Segment clone', $clientResponse->getContent());
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\HttpFoundation\Response;
final class NoteControllerTest extends MauticMysqlTestCase
{
protected function beforeBeginTransaction(): void
{
$this->resetAutoincrement([
'leads',
'companies',
'campaigns',
'categories',
'lead_lists',
]);
}
/**
* Quick smoke test to ensure the route is successful.
*/
public function testIndexActionsIsSuccessful(): void
{
$contact = (new Lead())->setFirstname('Test');
static::getContainer()->get('mautic.lead.model.lead')->saveEntity($contact);
$crawler = $this->client->request('GET', '/s/contacts/notes/'.$contact->getId());
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
/**
* Quick smoke test to ensure the route is successful.
*/
public function testNewActionsIsSuccessful(): void
{
$contact = (new Lead())->setFirstname('Test');
static::getContainer()->get('mautic.lead.model.lead')->saveEntity($contact);
$crawler = $this->client->request('GET', '/s/contacts/notes/'.$contact->getId().'/new');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Tests\Controller;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\Response;
final class TimelineControllerTest extends MauticMysqlTestCase
{
use CreateTestEntitiesTrait;
private const SALES_USER = 'sales';
public function testIndexActionsIsSuccessful(): void
{
$contact = $this->createLead('TestFirstName');
$this->em->flush();
$this->client->request('GET', '/s/contacts/timeline/'.$contact->getId());
$this->assertResponseIsSuccessful();
}
public function testFilterCaseInsensitive(): void
{
$contact = $this->createLead('TestFirstName');
$segment = $this->createSegment('TEST', []);
$this->createListLead($segment, $contact);
$this->em->flush();
$this->createLeadEventLogEntry($contact, 'lead', 'segment', 'added', $segment->getId(), [
'object_description' => $segment->getName(),
]);
$this->em->flush();
$this->client->request('POST', '/s/contacts/timeline/'.$contact->getId(), [
'search' => 'test',
'leadId' => $contact->getId(),
]);
$this->assertStringContainsString('Contact added to segment, TEST', $this->client->getResponse()->getContent());
}
/**
* @throws OptimisticLockException
* @throws ORMException
*/
public function testBatchExportActionAsAdmin(): void
{
$contact = $this->createLead('TestFirstName');
$this->em->persist($contact);
$this->em->flush();
$this->client->request('GET', '/s/contacts/timeline/batchExport/'.$contact->getId());
$this->assertResponseIsSuccessful();
}
public function testBatchExportActionAsUserNotPermission(): void
{
$contact = $this->createLead('TestFirstName');
$this->em->persist($contact);
$this->em->flush();
$user = $this->em->getRepository(User::class)->findOneBy(['username' => self::SALES_USER]);
$this->loginUser($user);
$this->client->request('GET', '/s/contacts/timeline/batchExport/'.$contact->getId());
$this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
}