diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c84493d07..73fb6088f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,9 @@ on: - '[0-9]+\.[0-9]+' - development - beta + - 'fix/**' pull_request: + workflow_dispatch: env: PLUGIN_DIR: plugins/CustomObjectsBundle # Same as extra.install-directory-name in composer.json diff --git a/.gitignore b/.gitignore index 67752a9e2..55cba76e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor/* /Tests/Coverage/* -.idea \ No newline at end of file +.idea +.superpowers/ \ No newline at end of file diff --git a/Config/config.php b/Config/config.php index 313fb8820..df583a48f 100644 --- a/Config/config.php +++ b/Config/config.php @@ -410,6 +410,7 @@ 'mautic.campaign.model.event', 'event_dispatcher', 'custom_object.helper.token_formatter', + 'custom_object.helper.filter_evaluator', '%mautic.custom_item_fetch_limit_per_lead%', ], ], @@ -636,6 +637,22 @@ 'custom_object.helper.token_formatter' => [ 'class' => MauticPlugin\CustomObjectsBundle\Helper\TokenFormatter::class, ], + 'custom_object.helper.filter_evaluator' => [ + 'class' => MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator::class, + ], + 'custom_object.helper.contact_filter_matcher' => [ + 'class' => MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher::class, + 'arguments' => [ + 'mautic.custom.model.field', + 'mautic.custom.model.object', + 'mautic.custom.model.item', + 'mautic.lead.repository.company', + 'doctrine.dbal.default_connection', + 'custom_object.helper.filter_evaluator', + '%mautic.custom_item_fetch_limit_per_lead%', + ], + ], + 'custom_object.data_persister.custom_item' => [ 'class' => MauticPlugin\CustomObjectsBundle\DataPersister\CustomItemDataPersister::class, 'tag' => 'api_platform.data_persister', diff --git a/Config/services.php b/Config/services.php index 2f7e94f21..c513e2f70 100644 --- a/Config/services.php +++ b/Config/services.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension; +use MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher; +use MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return function (ContainerConfigurator $configurator): void { @@ -17,10 +19,17 @@ 'Report/ReportColumnsBuilder.php', 'Serializer/ApiNormalizer.php', 'Extension/CustomItemListeningExtension.php', + // Registered explicitly in config.php so the int $leadCustomItemFetchLimit arg can be set + 'Helper/ContactFilterMatcher.php', + 'Helper/FilterEvaluator.php', ]; $services->load('MauticPlugin\\CustomObjectsBundle\\', '../') ->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}'); $services->load('MauticPlugin\\CustomObjectsBundle\\Repository\\', '../Repository/*Repository.php'); + + // Aliases so autowiring resolves the config.php-registered services by class name + $services->alias(ContactFilterMatcher::class, 'custom_object.helper.contact_filter_matcher')->public(); + $services->alias(FilterEvaluator::class, 'custom_object.helper.filter_evaluator')->public(); }; diff --git a/CustomObjectsBundle.php b/CustomObjectsBundle.php index 8340a5f79..c63854198 100644 --- a/CustomObjectsBundle.php +++ b/CustomObjectsBundle.php @@ -4,8 +4,6 @@ namespace MauticPlugin\CustomObjectsBundle; -use Doctrine\DBAL\Schema\Schema; -use Mautic\CoreBundle\Factory\MauticFactory; use Mautic\IntegrationsBundle\Bundle\AbstractPluginBundle; use MauticPlugin\CustomObjectsBundle\DependencyInjection\Compiler\CustomFieldTypePass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -19,15 +17,4 @@ public function build(ContainerBuilder $container): void { $container->addCompilerPass(new CustomFieldTypePass()); } - - /** - * In some rare cases it can happen that the plugin tables weren't created on plugin install. - * Create them on plugin update if they are missing. - */ - protected static function installAllTablesIfMissing(Schema $schema, string $tablePrefix, MauticFactory $factory, array $metadata = null): void - { - if (!$schema->hasTable($tablePrefix.'custom_object')) { - self::installPluginSchema($metadata, $factory, null); - } - } } diff --git a/EventListener/DynamicContentSubscriber.php b/EventListener/DynamicContentSubscriber.php index 054bcf5f6..dbef2642d 100644 --- a/EventListener/DynamicContentSubscriber.php +++ b/EventListener/DynamicContentSubscriber.php @@ -6,32 +6,23 @@ use Mautic\DynamicContentBundle\DynamicContentEvents; use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent; -use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait; -use MauticPlugin\CustomObjectsBundle\Exception\InvalidArgumentException; use MauticPlugin\CustomObjectsBundle\Exception\InvalidSegmentFilterException; -use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException; -use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper; +use MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher; use MauticPlugin\CustomObjectsBundle\Provider\ConfigProvider; -use MauticPlugin\CustomObjectsBundle\Repository\DbalQueryTrait; use MauticPlugin\CustomObjectsBundle\Segment\Query\Filter\QueryFilterFactory; -use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class DynamicContentSubscriber implements EventSubscriberInterface { - use MatchFilterForLeadTrait; - use DbalQueryTrait; - public function __construct( private QueryFilterFactory $queryFilterFactory, - private QueryFilterHelper $queryFilterHelper, + private ContactFilterMatcher $contactFilterMatcher, private ConfigProvider $configProvider, - private LoggerInterface $logger ) { } /** - * @return mixed[] + * @return array */ public static function getSubscribedEvents(): array { @@ -40,47 +31,38 @@ public static function getSubscribedEvents(): array ]; } - /** - * @throws InvalidArgumentException - * @throws NotFoundException - */ public function evaluateFilters(ContactFiltersEvaluateEvent $event): void { - if (!$this->configProvider->pluginIsEnabled()) { + if ($event->isEvaluated() + || !$this->configProvider->pluginIsEnabled() + || !$this->hasCustomObjectFilters($event->getFilters()) + ) { return; } - $eventFilters = $event->getFilters(); - - if ($event->isEvaluated()) { - return; - } - - foreach ($eventFilters as $key => $eventFilter) { - $queryAlias = "filter_{$key}"; - - try { - $filterQueryBuilder = $this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($eventFilter, $queryAlias); - } catch (InvalidSegmentFilterException $e) { - continue; - } - - $this->queryFilterHelper->addContactIdRestriction($filterQueryBuilder, $queryAlias, (int) $event->getContact()->getId()); + $event->setIsEvaluated(true); + $event->stopPropagation(); + $contact = $event->getContact(); + $event->setIsMatched($this->contactFilterMatcher->match( + $event->getFilters(), + array_merge(['id' => $contact->getId()], $contact->getProfileFields()) + )); + } + /** + * @param mixed[] $filters + */ + private function hasCustomObjectFilters(array $filters): bool + { + foreach ($filters as $filter) { try { - if ($this->executeSelect($filterQueryBuilder)->rowCount()) { - $event->setIsEvaluated(true); - $event->setIsMatched(true); - } else { - $event->setIsEvaluated(true); - } - } catch (\PDOException $e) { - $this->logger->error('Failed to evaluate dynamic content for custom object '.$e->getMessage()); + $this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($filter, 'filter'); - throw $e; + return true; + } catch (InvalidSegmentFilterException) { } - - $event->stopPropagation(); // The filter is ours, we won't allow no more processing } + + return false; } } diff --git a/EventListener/PluginSchemaSubscriber.php b/EventListener/PluginSchemaSubscriber.php new file mode 100644 index 000000000..2c1444a1c --- /dev/null +++ b/EventListener/PluginSchemaSubscriber.php @@ -0,0 +1,62 @@ + + */ + public static function getSubscribedEvents(): array + { + return [ + PluginEvents::ON_PLUGIN_INSTALL => 'onPluginInstall', + PluginEvents::ON_PLUGIN_UPDATE => 'onPluginUpdate', + ]; + } + + public function onPluginInstall(PluginInstallEvent $event): void + { + if (!$event->checkContext(self::PLUGIN_NAME)) { + return; + } + + $this->schemaInstaller->ensureTablesExist(); + } + + /** + * In some rare cases the plugin tables are not created on install. Create the + * missing ones on update so the plugin can recover without a reinstall. + */ + public function onPluginUpdate(PluginUpdateEvent $event): void + { + if (!$event->checkContext(self::PLUGIN_NAME)) { + return; + } + + $this->schemaInstaller->ensureTablesExist(); + } +} diff --git a/EventListener/TokenSubscriber.php b/EventListener/TokenSubscriber.php index 61b7cb8b6..f2d113ba2 100644 --- a/EventListener/TokenSubscriber.php +++ b/EventListener/TokenSubscriber.php @@ -11,10 +11,7 @@ use Mautic\EmailBundle\EmailEvents; use Mautic\EmailBundle\Entity\Email; use Mautic\EmailBundle\Event\EmailSendEvent; -use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait; use Mautic\LeadBundle\Entity\LeadList; -use Mautic\LeadBundle\Exception\OperatorsNotFoundException; -use Mautic\LeadBundle\Segment\OperatorOptions; use MauticPlugin\CustomObjectsBundle\CustomItemEvents; use MauticPlugin\CustomObjectsBundle\CustomObjectEvents; use MauticPlugin\CustomObjectsBundle\DTO\TableConfig; @@ -28,6 +25,7 @@ use MauticPlugin\CustomObjectsBundle\Exception\InvalidCustomObjectFormatListException; use MauticPlugin\CustomObjectsBundle\Exception\InvalidSegmentFilterException; use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException; +use MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator; use MauticPlugin\CustomObjectsBundle\Helper\QueryBuilderManipulatorTrait; use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper; use MauticPlugin\CustomObjectsBundle\Helper\TokenFormatter; @@ -45,7 +43,6 @@ */ class TokenSubscriber implements EventSubscriberInterface { - use MatchFilterForLeadTrait; use QueryBuilderManipulatorTrait; public function __construct( @@ -59,6 +56,7 @@ public function __construct( private EventModel $eventModel, private EventDispatcherInterface $eventDispatcher, private TokenFormatter $tokenFormatter, + private FilterEvaluator $filterEvaluator, private int $leadCustomItemFetchLimit ) { } @@ -300,7 +298,7 @@ public function onTokenReplacement(TokenReplacementEvent $event): void $lead = array_merge($lead, $customFieldValues); } - if ($isCustomObject && $this->matchFilterForLeadInCustomObject($filter['filters'], $lead)) { + if ($isCustomObject && $this->filterEvaluator->evaluate($filter['filters'], $lead)) { $filterContent = $filter['content']; break; } @@ -418,179 +416,4 @@ private function getCustomItems(CustomObject $customObject, string $leadId): arr return $this->customItemModel->getArrayTableData($tableConfig); } - - // We have a similar function in MatchFilterForLeadTrait since we are unable to alter anything in Mautic 4.4, - // hence there is some duplication of code. - - /** - * @param array $filter - * @param array $lead - * - * @throws OperatorsNotFoundException - */ - protected function matchFilterForLeadInCustomObject(array $filter, array $lead): bool - { - if (empty($lead['id'])) { - // Lead in generated for preview with faked data - return false; - } - - $groups = []; - $groupNum = 0; - - foreach ($filter as $data) { - if (!array_key_exists($data['field'], $lead)) { - continue; - } - - /* - * Split the filters into groups based on the glue. - * The first filter and any filters whose glue is - * "or" will start a new group. - */ - if (0 === $groupNum || 'or' === $data['glue']) { - ++$groupNum; - $groups[$groupNum] = null; - } - - /* - * If the group has been marked as false, there - * is no need to continue checking the others - * in the group. - */ - if (false === $groups[$groupNum]) { - continue; - } - - /* - * If we are checking the first filter in a group - * assume that the group will not match. - */ - if (null === $groups[$groupNum]) { - $groups[$groupNum] = false; - } - - $leadValues = $lead[$data['field']]; - $leadValues = 'custom_object' === $data['object'] ? $leadValues : [$leadValues]; - $filterVal = $data['filter']; - $subgroup = null; - - if (is_array($leadValues)) { - foreach ($leadValues as $leadVal) { - if ($subgroup) { - break; - } - - switch ($data['type']) { - case 'boolean': - if (null !== $leadVal) { - $leadVal = (bool) $leadVal; - } - - if (null !== $filterVal) { - $filterVal = (bool) $filterVal; - } - break; - case 'datetime': - case 'time': - if (!is_null($leadVal) && !is_null($filterVal)) { - $leadValCount = substr_count($leadVal, ':'); - $filterValCount = substr_count($filterVal, ':'); - - if (2 === $leadValCount && 1 === $filterValCount) { - $filterVal .= ':00'; - } - } - break; - case 'tags': - case 'select': - case 'multiselect': - if (!is_array($leadVal) && !empty($leadVal)) { - $leadVal = explode('|', $leadVal); - } - if (!is_null($filterVal) && !is_array($filterVal)) { - $filterVal = explode('|', $filterVal); - } - break; - case 'number': - $leadVal = (int) $leadVal; - $filterVal = (int) $filterVal; - break; - } - - switch ($data['operator']) { - case '=': - if ('boolean' === $data['type']) { - $groups[$groupNum] = $leadVal === $filterVal; - } else { - $groups[$groupNum] = $leadVal == $filterVal; - } - break; - case '!=': - if ('boolean' === $data['type']) { - $groups[$groupNum] = $leadVal !== $filterVal; - } else { - $groups[$groupNum] = $leadVal != $filterVal; - } - break; - case 'gt': - $groups[$groupNum] = $leadVal > $filterVal; - break; - case 'gte': - $groups[$groupNum] = $leadVal >= $filterVal; - break; - case 'lt': - $groups[$groupNum] = $leadVal < $filterVal; - break; - case 'lte': - $groups[$groupNum] = $leadVal <= $filterVal; - break; - case 'empty': - $groups[$groupNum] = empty($leadVal); - break; - case '!empty': - $groups[$groupNum] = !empty($leadVal); - break; - case 'like': - $matchVal = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal); - $groups[$groupNum] = 1 === preg_match('/'.$matchVal.'/', $leadVal); - break; - case '!like': - $matchVal = str_replace(['.', '*'], ['\.', '\*'], $filterVal); - $matchVal = str_replace('%', '.*', $matchVal); - $groups[$groupNum] = 1 !== preg_match('/'.$matchVal.'/', $leadVal); - break; - case OperatorOptions::IN: - $groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, false); - break; - case OperatorOptions::NOT_IN: - $groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, true); - break; - case 'regexp': - $groups[$groupNum] = 1 === preg_match('/'.$filterVal.'/i', $leadVal); - break; - case '!regexp': - $groups[$groupNum] = 1 !== preg_match('/'.$filterVal.'/i', $leadVal); - break; - case 'startsWith': - $groups[$groupNum] = str_starts_with($leadVal, $filterVal); - break; - case 'endsWith': - $endOfString = substr($leadVal, strlen($leadVal) - strlen($filterVal)); - $groups[$groupNum] = 0 === strcmp($endOfString, $filterVal); - break; - case 'contains': - $groups[$groupNum] = str_contains((string) $leadVal, (string) $filterVal); - break; - default: - throw new OperatorsNotFoundException('Operator is not defined or invalid operator found.'); - } - - $subgroup = $groups[$groupNum]; - } - } - } - - return in_array(true, $groups); - } } diff --git a/Helper/ContactFilterMatcher.php b/Helper/ContactFilterMatcher.php new file mode 100644 index 000000000..ccf1550a3 --- /dev/null +++ b/Helper/ContactFilterMatcher.php @@ -0,0 +1,212 @@ +getCustomFieldDataForLead($filters, $leadId); + + if (!$customFieldValues) { + return false; + } + + $hasCustomFields = true; + $lead = array_merge($lead, $customFieldValues); + + if (!isset($lead['companies']) && $this->doFiltersContainCompanyFilter($filters)) { + $lead['companies'] = $this->companyRepository->getCompaniesByLeadId($leadId); + } + + if (!isset($lead['tags']) && $this->doFiltersContainTagsFilter($filters)) { + $lead['tags'] = $this->getTagIdsByLeadId($leadId); + } + + return $this->filterEvaluator->evaluate($filters, $lead); + } + + /** + * @param mixed[] $filters + * + * @return mixed[] + */ + private function getCustomFieldDataForLead(array $filters, string $leadId): array + { + $customFieldValues = $cachedCustomItems = []; + + foreach ($filters as $condition) { + try { + if ('custom_object' !== $condition['object']) { + continue; + } + + if (str_starts_with($condition['field'], 'cmf_')) { + $customField = $this->customFieldModel->fetchEntity( + (int) explode('cmf_', $condition['field'])[1] + ); + $customObject = $customField->getCustomObject(); + $fieldAlias = $customField->getAlias(); + } elseif (str_starts_with($condition['field'], 'cmo_')) { + $customObject = $this->customObjectModel->fetchEntity( + (int) explode('cmo_', $condition['field'])[1] + ); + $fieldAlias = 'name'; + } else { + continue; + } + + $key = $customObject->getId().'-'.$leadId; + if (!isset($cachedCustomItems[$key])) { + $cachedCustomItems[$key] = $this->getCustomItems($customObject, $leadId); + } + + $result = $this->getCustomFieldValue($customObject, $fieldAlias, $cachedCustomItems[$key]); + + $customFieldValues[$condition['field']] = $result; + } catch (NotFoundException|InvalidCustomObjectFormatListException) { + continue; + } + } + + return $customFieldValues; + } + + /** + * @param mixed[] $customItems + * + * @return mixed[] + */ + private function getCustomFieldValue( + CustomObject $customObject, + string $customFieldAlias, + array $customItems + ): array { + $fieldValues = []; + + foreach ($customItems as $customItemData) { + // Name is known from the CI data array. + if ('name' === $customFieldAlias) { + $fieldValues[] = $customItemData['name']; + + continue; + } + + // Custom Field values are handled like this. + $customItem = new CustomItem($customObject); + $customItem->populateFromArray($customItemData); + $customItem = $this->customItemModel->populateCustomFields($customItem); + + try { + $fieldValue = $customItem->findCustomFieldValueForFieldAlias($customFieldAlias); + // If the CO item doesn't have a value, get the default value + if (empty($fieldValue->getValue())) { + $fieldValue->setValue($fieldValue->getCustomField()->getDefaultValue()); + } + + if (in_array($fieldValue->getCustomField()->getType(), ['multiselect', 'select'])) { + $fieldValues[] = $fieldValue->getValue(); + } else { + $fieldValues[] = $fieldValue->getCustomField()->getTypeObject()->valueToString($fieldValue); + } + } catch (NotFoundException) { + // Custom field not found. + } + } + + return $fieldValues; + } + + /** + * @return array + */ + private function getCustomItems(CustomObject $customObject, string $leadId): array + { + $orderBy = CustomItem::TABLE_ALIAS.'.id'; + $orderDir = 'DESC'; + + $tableConfig = new TableConfig($this->leadCustomItemFetchLimit, 1, $orderBy, $orderDir); + $tableConfig->addParameter('customObjectId', $customObject->getId()); + $tableConfig->addParameter('filterEntityType', 'contact'); + $tableConfig->addParameter('filterEntityId', $leadId); + + return $this->customItemModel->getArrayTableData($tableConfig); + } + + /** + * @param mixed[] $filters + */ + private function doFiltersContainCompanyFilter(array $filters): bool + { + foreach ($filters as $filter) { + $object = $filter['object'] ?? ''; + + if ('company' === $object) { + return true; + } + + if (str_starts_with($filter['field'], 'company') && 'company' !== $filter['field']) { + return true; + } + } + + return false; + } + + /** + * @param mixed[] $filters + */ + private function doFiltersContainTagsFilter(array $filters): bool + { + foreach ($filters as $filter) { + if ('tags' === ($filter['type'] ?? null)) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + public function getTagIdsByLeadId(string $leadId): array + { + return $this->connection->createQueryBuilder() + ->select('tag_id') + ->from(MAUTIC_TABLE_PREFIX.'lead_tags_xref', 'x') + ->where('x.lead_id = :leadId') + ->setParameter('leadId', $leadId) + ->executeQuery() + ->fetchFirstColumn(); + } +} diff --git a/Helper/FilterEvaluator.php b/Helper/FilterEvaluator.php new file mode 100644 index 000000000..4929f1f1b --- /dev/null +++ b/Helper/FilterEvaluator.php @@ -0,0 +1,194 @@ + + */ +class FilterEvaluator +{ + /** + * @param array $filters + * @param LeadArray $lead + * + * @throws OperatorsNotFoundException + */ + public function evaluate(array $filters, array $lead): bool + { + if (empty($lead['id'])) { + return false; + } + + /** @var array $groups */ + $groups = []; + $groupNum = 0; + + foreach ($filters as $data) { + if (!array_key_exists($data['field'], $lead)) { + continue; + } + + if (0 === $groupNum || 'or' === $data['glue']) { + ++$groupNum; + $groups[$groupNum] = null; + } + + if (false === $groups[$groupNum]) { + continue; + } + + if (null === $groups[$groupNum]) { + $groups[$groupNum] = false; + } + + $isCustomObject = 'custom_object' === $data['object']; + $leadValues = $isCustomObject ? $lead[$data['field']] : [$lead[$data['field']]]; + // Dynamic content stores the value flat as $data['filter'], whereas segments nest it + // under $data['properties']['filter']. Both callers here pass dynamic content filters + // (DynamicContentSubscriber and TokenSubscriber::onTokenReplacement), so read it flat. + $filterVal = $data['filter']; + + if (!is_array($leadValues)) { + $leadValues = [$leadValues]; + } + + // No linked custom items: only 'empty' operator can match. + if ($isCustomObject && [] === $leadValues) { + $groups[$groupNum] = 'empty' === $data['operator']; + continue; + } + + $matched = false; + foreach ($leadValues as $leadVal) { + [$leadVal, $filterVal] = $this->coerceTypes($data['type'], $leadVal, $filterVal); + if ($this->applyOperator($data['operator'], $data['type'], $leadVal, $filterVal)) { + $matched = true; + break; + } + } + + $groups[$groupNum] = $matched; + } + + return in_array(true, $groups, true); + } + + /** + * @return array{mixed, mixed} + */ + private function coerceTypes(string $type, mixed $leadVal, mixed $filterVal): array + { + switch ($type) { + case 'boolean': + if (null !== $leadVal) { + $leadVal = (bool) $leadVal; + } + if (null !== $filterVal) { + $filterVal = (bool) $filterVal; + } + break; + case 'datetime': + case 'time': + if (null !== $leadVal && null !== $filterVal) { + if (2 === substr_count($leadVal, ':') && 1 === substr_count($filterVal, ':')) { + $filterVal .= ':00'; + } + } + break; + case 'tags': + case 'select': + case 'multiselect': + if (!is_array($leadVal) && !empty($leadVal)) { + $leadVal = explode('|', $leadVal); + } + if (null !== $filterVal && !is_array($filterVal)) { + $filterVal = explode('|', $filterVal); + } + break; + case 'int': + case 'number': + $leadVal = (int) $leadVal; + $filterVal = (int) $filterVal; + break; + } + + return [$leadVal, $filterVal]; + } + + /** + * @throws OperatorsNotFoundException + */ + private function applyOperator(string $operator, string $type, mixed $leadVal, mixed $filterVal): bool + { + switch ($operator) { + case '=': + return 'boolean' === $type ? $leadVal === $filterVal : $leadVal == $filterVal; + case '!=': + return 'boolean' === $type ? $leadVal !== $filterVal : $leadVal != $filterVal; + case 'gt': + return $leadVal > $filterVal; + case 'gte': + return $leadVal >= $filterVal; + case 'lt': + return $leadVal < $filterVal; + case 'lte': + return $leadVal <= $filterVal; + case 'empty': + return empty($leadVal); + case '!empty': + return !empty($leadVal); + case 'like': + $pattern = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal); + + return 1 === preg_match('/'.$pattern.'/', $leadVal); + case '!like': + $pattern = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal); + + return 1 !== preg_match('/'.$pattern.'/', $leadVal); + case OperatorOptions::IN: + return $this->checkLeadValueIsInFilter($leadVal, $filterVal, false); + case OperatorOptions::NOT_IN: + return $this->checkLeadValueIsInFilter($leadVal, $filterVal, true); + case 'regexp': + return 1 === preg_match('/'.$filterVal.'/i', $leadVal); + case '!regexp': + return 1 !== preg_match('/'.$filterVal.'/i', $leadVal); + case 'startsWith': + return str_starts_with($leadVal, $filterVal); + case 'endsWith': + return 0 === strcmp(substr($leadVal, strlen($leadVal) - strlen($filterVal)), $filterVal); + case 'contains': + return str_contains((string) $leadVal, (string) $filterVal); + default: + throw new OperatorsNotFoundException('Operator is not defined or invalid operator found.'); + } + } + + private function checkLeadValueIsInFilter(mixed $leadVal, mixed $filterVal, bool $defaultFlag): bool + { + $leadVal = !is_array($leadVal) ? [$leadVal] : $leadVal; + $filterVal = !is_array($filterVal) ? [$filterVal] : $filterVal; + $retFlag = $defaultFlag; + + foreach ($leadVal as $v) { + if (in_array($v, $filterVal)) { + $retFlag = !$defaultFlag; + break; + } + } + + return $retFlag; + } +} diff --git a/Helper/PluginSchemaInstaller.php b/Helper/PluginSchemaInstaller.php new file mode 100644 index 000000000..270393b7b --- /dev/null +++ b/Helper/PluginSchemaInstaller.php @@ -0,0 +1,63 @@ +entityManager->getConnection(); + $schema = $connection->createSchemaManager()->introspectSchema(); + + $missing = array_values(array_filter( + $this->getPluginMetadata(), + fn (ClassMetadata $metadata): bool => !$schema->hasTable($metadata->getTableName()) + )); + + if ([] === $missing) { + return; + } + + $schemaTool = new SchemaTool($this->entityManager); + + foreach ($schemaTool->getCreateSchemaSql($missing) as $query) { + $connection->executeStatement($query); + } + } + + /** + * @return array> + */ + private function getPluginMetadata(): array + { + return array_filter( + $this->entityManager->getMetadataFactory()->getAllMetadata(), + fn (ClassMetadata $metadata): bool => str_starts_with($metadata->getName(), self::ENTITY_NAMESPACE) + && !$metadata->isMappedSuperclass + && !$metadata->isEmbeddedClass + ); + } +} diff --git a/Tests/Functional/EventListener/DynamicContentSubscriberTest.php b/Tests/Functional/EventListener/DynamicContentSubscriberTest.php index 46a9460ae..cd09ae3ce 100644 --- a/Tests/Functional/EventListener/DynamicContentSubscriberTest.php +++ b/Tests/Functional/EventListener/DynamicContentSubscriberTest.php @@ -4,17 +4,552 @@ namespace MauticPlugin\CustomObjectsBundle\Tests\Functional\EventListener; -use Mautic\DynamicContentBundle\DynamicContentEvents; +use Mautic\CoreBundle\Test\MauticMysqlTestCase; +use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent; +use Mautic\LeadBundle\Entity\Lead; +use Mautic\LeadBundle\Model\LeadModel; +use MauticPlugin\CustomObjectsBundle\Entity\CustomItem; use MauticPlugin\CustomObjectsBundle\EventListener\DynamicContentSubscriber; -use PHPUnit\Framework\TestCase; +use MauticPlugin\CustomObjectsBundle\Model\CustomFieldValueModel; +use MauticPlugin\CustomObjectsBundle\Model\CustomItemModel; +use MauticPlugin\CustomObjectsBundle\Tests\Functional\DataFixtures\Traits\CustomObjectsTrait; -class DynamicContentSubscriberTest extends TestCase +#[\AllowDynamicProperties] +class DynamicContentSubscriberTest extends MauticMysqlTestCase { - public function testSubscribesToEvent(): void + use CustomObjectsTrait; + + private CustomItemModel $customItemModel; + private CustomFieldValueModel $customFieldValueModel; + private DynamicContentSubscriber $subscriber; + + protected function setUp(): void + { + parent::setUp(); + + $this->customItemModel = self::$container->get('mautic.custom.model.item'); + $this->customFieldValueModel = self::$container->get('mautic.custom.model.field.value'); + $this->subscriber = self::$container->get(DynamicContentSubscriber::class); + } + + public function testVariousOperators(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('operators@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $textValue = $customItem->findCustomFieldValueForFieldAlias('text-test-field'); + $urlValue = $customItem->findCustomFieldValueForFieldAlias('url-test-field'); + $dateValue = $customItem->findCustomFieldValueForFieldAlias('date-test-field'); + $datetimeValue = $customItem->findCustomFieldValueForFieldAlias('datetime-test-field'); + $multiselectValue = $customItem->findCustomFieldValueForFieldAlias('multiselect-test-field'); + + $textValue->setValue('abracadabra'); + $dateValue->setValue('2019-07-17'); + $datetimeValue->setValue('2019-07-17 13:00:00'); + $multiselectValue->setValue(['option_b']); + // urlValue left empty intentionally + + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $textFieldId = $textValue->getCustomField()->getId(); + $urlFieldId = $urlValue->getCustomField()->getId(); + $dateFieldId = $dateValue->getCustomField()->getId(); + $datetimeFieldId = $datetimeValue->getCustomField()->getId(); + $multiselectFieldId = $multiselectValue->getCustomField()->getId(); + + // = match + $this->assertMatched($contact, $textFieldId, 'text', '=', 'abracadabra'); + + // = no match + $this->assertNotMatched($contact, $textFieldId, 'text', '=', 'unicorn'); + + // != match + $this->assertMatched($contact, $textFieldId, 'text', '!=', 'unicorn'); + + // != no match + $this->assertNotMatched($contact, $textFieldId, 'text', '!=', 'abracadabra'); + + // empty — url field has no value + $this->assertMatched($contact, $urlFieldId, 'text', 'empty', null); + + // !empty — text field has a value + $this->assertMatched($contact, $textFieldId, 'text', '!empty', null); + + // !empty — url field is empty, so !empty should not match + $this->assertNotMatched($contact, $urlFieldId, 'text', '!empty', null); + + // startsWith match + $this->assertMatched($contact, $textFieldId, 'text', 'startsWith', 'abra'); + + // startsWith no match + $this->assertNotMatched($contact, $textFieldId, 'text', 'startsWith', 'unicorn'); + + // endsWith match + $this->assertMatched($contact, $textFieldId, 'text', 'endsWith', 'cadabra'); + + // endsWith no match + $this->assertNotMatched($contact, $textFieldId, 'text', 'endsWith', 'unicorn'); + + // contains match + $this->assertMatched($contact, $textFieldId, 'text', 'contains', 'cada'); + + // contains no match + $this->assertNotMatched($contact, $textFieldId, 'text', 'contains', 'unicorn'); + + // like with % wildcard match + $this->assertMatched($contact, $textFieldId, 'text', 'like', 'abra%'); + + // like no match + $this->assertNotMatched($contact, $textFieldId, 'text', 'like', 'unicorn%'); + + // !like no match (value matches pattern, so !like is false) + $this->assertNotMatched($contact, $textFieldId, 'text', '!like', 'abra%'); + + // !like match (value does not match pattern) + $this->assertMatched($contact, $textFieldId, 'text', '!like', 'unicorn%'); + + // in (multiselect) match + $this->assertMatched($contact, $multiselectFieldId, 'multiselect', 'in', ['option_b']); + + // in (multiselect) no match + $this->assertNotMatched($contact, $multiselectFieldId, 'multiselect', 'in', ['option_a']); + + // !in (multiselect) match + $this->assertMatched($contact, $multiselectFieldId, 'multiselect', '!in', ['option_a']); + + // !in (multiselect) no match (value is in list) + $this->assertNotMatched($contact, $multiselectFieldId, 'multiselect', '!in', ['option_b']); + + // date lt match + $this->assertMatched($contact, $dateFieldId, 'date', 'lt', '2019-08-05'); + + // date lt no match + $this->assertNotMatched($contact, $dateFieldId, 'date', 'lt', '2019-06-05'); + + // date gt match + $this->assertMatched($contact, $dateFieldId, 'date', 'gt', '2019-06-05'); + + // date gt no match + $this->assertNotMatched($contact, $dateFieldId, 'date', 'gt', '2019-08-05'); + + // datetime gt match + $this->assertMatched($contact, $datetimeFieldId, 'datetime', 'gt', '2019-07-16 13:00:00'); + + // datetime gt no match + $this->assertNotMatched($contact, $datetimeFieldId, 'datetime', 'gt', '2019-07-18 13:00:00'); + } + + public function testAndFiltersAllMustMatch(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('and-match@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $textValue = $customItem->findCustomFieldValueForFieldAlias('text-test-field'); + $urlValue = $customItem->findCustomFieldValueForFieldAlias('url-test-field'); + $textValue->setValue('abracadabra'); + $urlValue->setValue('https://example.com'); + + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$textValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => 'abracadabra', + 'display' => null, + 'operator' => '=', + ], + [ + 'glue' => 'and', + 'field' => 'cmf_'.$urlValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => null, + 'display' => null, + 'operator' => '!empty', + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertTrue($event->isMatched(), 'Both AND conditions are true, should match'); + } + + public function testAndFiltersOneFailsNoMatch(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('and-no-match@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $textValue = $customItem->findCustomFieldValueForFieldAlias('text-test-field'); + $urlValue = $customItem->findCustomFieldValueForFieldAlias('url-test-field'); + $textValue->setValue('abracadabra'); + // urlValue left empty intentionally + + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$textValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => 'abracadabra', + 'display' => null, + 'operator' => '=', + ], + [ + 'glue' => 'and', + 'field' => 'cmf_'.$urlValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => null, + 'display' => null, + 'operator' => '!empty', // url is empty, so this fails + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertFalse($event->isMatched(), 'One AND condition fails, should not match'); + } + + public function testOrFiltersSecondGroupMatches(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('or-match@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $textValue = $customItem->findCustomFieldValueForFieldAlias('text-test-field'); + $urlValue = $customItem->findCustomFieldValueForFieldAlias('url-test-field'); + $textValue->setValue('abracadabra'); + $urlValue->setValue('https://example.com'); + + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$textValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => 'unicorn', // first group fails + 'display' => null, + 'operator' => '=', + ], + [ + 'glue' => 'or', // starts a new group + 'field' => 'cmf_'.$urlValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => null, + 'display' => null, + 'operator' => '!empty', // second group passes + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertTrue($event->isMatched(), 'Second OR group passes, overall result should match'); + } + + public function testMultipleLinkedItemsAnyMatchWins(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('multi-item@example.com'); + + $itemA = new CustomItem($customObject); + $itemA->setName('Item A'); + $this->customFieldValueModel->createValuesForItem($itemA); + $itemA->findCustomFieldValueForFieldAlias('text-test-field')->setValue('basic'); + $itemA = $this->customItemModel->save($itemA); + $this->customItemModel->linkEntity($itemA, 'contact', (int) $contact->getId()); + + $itemB = new CustomItem($customObject); + $itemB->setName('Item B'); + $this->customFieldValueModel->createValuesForItem($itemB); + $textValueB = $itemB->findCustomFieldValueForFieldAlias('text-test-field'); + $textValueB->setValue('premium'); + $itemB = $this->customItemModel->save($itemB); + $this->customItemModel->linkEntity($itemB, 'contact', (int) $contact->getId()); + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$textValueB->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => 'premium', + 'display' => null, + 'operator' => '=', + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertTrue($event->isMatched(), 'When any linked item matches, overall result should match'); + } + + public function testEmptyOperatorMatchesContactWithNoLinkedItems(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('no-items@example.com'); + // No custom item is created or linked to this contact + + // To make hasCustomObjectFilters() pass (so the subscriber takes ownership), + // we still need a filter with a valid custom field ID. Create a throwaway item + // just to get a field ID, but don't link it to this contact. + $tempItem = new CustomItem($customObject); + $tempItem->setName('Temp'); + $this->customFieldValueModel->createValuesForItem($tempItem); + $textValue = $tempItem->findCustomFieldValueForFieldAlias('text-test-field'); + $this->customItemModel->save($tempItem); + // Not linked to $contact + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$textValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => null, + 'display' => null, + 'operator' => 'empty', + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertTrue($event->isMatched(), 'Contact with no linked items should match the "empty" operator'); + } + + public function testAlreadyEvaluatedEventIsSkipped(): void + { + // No DB setup needed — the subscriber bails out before touching the DB. + $contact = new Lead(); + $event = new ContactFiltersEvaluateEvent([], $contact); + $event->setIsEvaluated(true); // already handled by another listener + $event->setIsMatched(false); // prior result was false + + $this->subscriber->evaluateFilters($event); + + $this->assertFalse($event->isMatched(), 'Subscriber must not overwrite a result already set by another listener'); + } + + public function testRegexpOperator(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('regexp@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $textValue = $customItem->findCustomFieldValueForFieldAlias('text-test-field'); + $textValue->setValue('abracadabra'); + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $fieldId = $textValue->getCustomField()->getId(); + + // regexp match + $this->assertMatched($contact, $fieldId, 'text', 'regexp', 'abra.*cadabra'); + + // regexp no match + $this->assertNotMatched($contact, $fieldId, 'text', 'regexp', '^unicorn'); + + // !regexp match (value does not match pattern) + $this->assertMatched($contact, $fieldId, 'text', '!regexp', '^unicorn'); + + // !regexp no match (value matches pattern) + $this->assertNotMatched($contact, $fieldId, 'text', '!regexp', 'abra.*cadabra'); + } + + public function testNumberFieldComparisons(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('number@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $intValue = $customItem->findCustomFieldValueForFieldAlias('number-test-field'); + $intValue->setValue(42); + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $fieldId = $intValue->getCustomField()->getId(); + + // = match / no match + $this->assertMatched($contact, $fieldId, 'int', '=', 42); + $this->assertNotMatched($contact, $fieldId, 'int', '=', 99); + + // gt / gte + $this->assertMatched($contact, $fieldId, 'int', 'gt', 10); + $this->assertNotMatched($contact, $fieldId, 'int', 'gt', 42); + $this->assertMatched($contact, $fieldId, 'int', 'gte', 42); + $this->assertNotMatched($contact, $fieldId, 'int', 'gte', 43); + + // lt / lte + $this->assertMatched($contact, $fieldId, 'int', 'lt', 99); + $this->assertNotMatched($contact, $fieldId, 'int', 'lt', 42); + $this->assertMatched($contact, $fieldId, 'int', 'lte', 42); + $this->assertNotMatched($contact, $fieldId, 'int', 'lte', 41); + } + + public function testOrFiltersFirstGroupPassesSecondFails(): void + { + $customObject = $this->createCustomObjectWithAllFields(self::$container, 'Product'); + $contact = $this->createContact('or-first-passes@example.com'); + $customItem = new CustomItem($customObject); + $customItem->setName('Test Item'); + $this->customFieldValueModel->createValuesForItem($customItem); + + $textValue = $customItem->findCustomFieldValueForFieldAlias('text-test-field'); + $urlValue = $customItem->findCustomFieldValueForFieldAlias('url-test-field'); + $textValue->setValue('abracadabra'); + // urlValue left empty + + $customItem = $this->customItemModel->save($customItem); + $this->customItemModel->linkEntity($customItem, 'contact', (int) $contact->getId()); + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$textValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => 'abracadabra', + 'display' => null, + 'operator' => '=', // first group passes + ], + [ + 'glue' => 'or', // starts a second group + 'field' => 'cmf_'.$urlValue->getCustomField()->getId(), + 'object' => 'custom_object', + 'type' => 'text', + 'filter' => null, + 'display' => null, + 'operator' => '!empty', // second group fails (url is empty) + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertTrue($event->isMatched(), 'First OR group passes — overall result must be true even though second group fails'); + } + + public function testEvaluateFiltersSkipsEventWithNoCustomObjectFilters(): void + { + $contact = $this->createContact('test-skip@example.com'); + + $filters = [ + [ + 'glue' => 'and', + 'field' => 'email', + 'object' => 'lead', + 'type' => 'email', + 'filter' => 'test-skip@example.com', + 'display' => null, + 'operator' => '=', + ], + ]; + + $event = new ContactFiltersEvaluateEvent($filters, $contact); + $this->subscriber->evaluateFilters($event); + + $this->assertFalse($event->isEvaluated(), 'Subscriber should not take ownership when no custom object filters are present'); + } + + /** + * @param mixed $filterValue + */ + private function assertMatched(Lead $contact, int $fieldId, string $type, string $operator, $filterValue): void + { + $event = new ContactFiltersEvaluateEvent( + $this->buildFilter($fieldId, $type, $operator, $filterValue), + $contact + ); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue( + $event->isMatched(), + "Expected match for operator '{$operator}' with filter value '".json_encode($filterValue)."'" + ); + } + + /** + * @param mixed $filterValue + */ + private function assertNotMatched(Lead $contact, int $fieldId, string $type, string $operator, $filterValue): void + { + $event = new ContactFiltersEvaluateEvent( + $this->buildFilter($fieldId, $type, $operator, $filterValue), + $contact + ); + $this->subscriber->evaluateFilters($event); + + $this->assertFalse( + $event->isMatched(), + "Expected no match for operator '{$operator}' with filter value '".json_encode($filterValue)."'" + ); + } + + /** + * @param mixed $filterValue + * + * @return array> + */ + private function buildFilter(int $fieldId, string $type, string $operator, $filterValue): array + { + return [ + [ + 'glue' => 'and', + 'field' => 'cmf_'.$fieldId, + 'object' => 'custom_object', + 'type' => $type, + 'filter' => $filterValue, + 'display' => null, + 'operator' => $operator, + ], + ]; + } + + private function createContact(string $email): Lead { - $eventSubscriptions = DynamicContentSubscriber::getSubscribedEvents(); - $methodName = $eventSubscriptions[DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE][0]; + /** @var LeadModel $contactModel */ + $contactModel = self::$container->get('mautic.lead.model.lead'); + $contact = new Lead(); + $contact->setEmail($email); + $contactModel->saveEntity($contact); - $this->assertSame('evaluateFilters', $methodName); + return $contact; } } diff --git a/Tests/Unit/EventListener/DynamicContentSubscriberTest.php b/Tests/Unit/EventListener/DynamicContentSubscriberTest.php index 6f28959d0..5bfaf35e6 100644 --- a/Tests/Unit/EventListener/DynamicContentSubscriberTest.php +++ b/Tests/Unit/EventListener/DynamicContentSubscriberTest.php @@ -4,152 +4,176 @@ namespace MauticPlugin\CustomObjectsBundle\Tests\Unit\EventListener; -use Doctrine\DBAL\Result; -use Doctrine\DBAL\Statement; +use Mautic\DynamicContentBundle\DynamicContentEvents; use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent; use Mautic\LeadBundle\Entity\Lead; -use Mautic\LeadBundle\Segment\Query\QueryBuilder; use MauticPlugin\CustomObjectsBundle\EventListener\DynamicContentSubscriber; use MauticPlugin\CustomObjectsBundle\Exception\InvalidSegmentFilterException; -use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper; +use MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher; use MauticPlugin\CustomObjectsBundle\Provider\ConfigProvider; use MauticPlugin\CustomObjectsBundle\Segment\Query\Filter\CustomFieldFilterQueryBuilder; use MauticPlugin\CustomObjectsBundle\Segment\Query\Filter\CustomItemNameFilterQueryBuilder; use MauticPlugin\CustomObjectsBundle\Segment\Query\Filter\QueryFilterFactory; -use Monolog\Logger; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class DynamicContentSubscriberTest extends TestCase { - /** @var ConfigProvider|MockObject */ - private $configProviderMock; + /** @var QueryFilterFactory&MockObject */ + private MockObject $queryFilterFactory; - /** @var QueryFilterHelper|MockObject */ - private $queryFilterHelperMock; + /** @var ContactFilterMatcher&MockObject */ + private MockObject $contactFilterMatcher; - /** @var QueryFilterFactory|MockObject */ - private $queryFilterFactory; + /** @var ConfigProvider&MockObject */ + private MockObject $configProvider; - /** @var Logger|MockObject */ - private $loggerMock; - - /** @var QueryBuilder|MockObject */ - private $queryBuilderMock; - - /** @var DynamicContentSubscriber */ - private $dynamicContentSubscriber; - - /** @var Statement|MockObject */ - private $statementMock; + private DynamicContentSubscriber $subscriber; protected function setUp(): void { parent::setUp(); - $this->configProviderMock = $this->createMock(ConfigProvider::class); - $this->queryFilterFactory = $this->createMock(QueryFilterFactory::class); - $this->queryFilterHelperMock = $this->createMock(QueryFilterHelper::class); - $this->loggerMock = $this->createMock(Logger::class); - $this->queryBuilderMock = $this->createMock(QueryBuilder::class); - $this->statementMock = $this->createMock(Statement::class); + $this->queryFilterFactory = $this->createMock(QueryFilterFactory::class); + $this->contactFilterMatcher = $this->createMock(ContactFilterMatcher::class); + $this->configProvider = $this->createMock(ConfigProvider::class); - $this->dynamicContentSubscriber = new DynamicContentSubscriber( + $this->subscriber = new DynamicContentSubscriber( $this->queryFilterFactory, - $this->queryFilterHelperMock, - $this->configProviderMock, - $this->loggerMock + $this->contactFilterMatcher, + $this->configProvider, ); } - public function testOnCampaignBuildWhenPluginDisabled(): void + public function testGetSubscribedEventsReturnsCorrectMapping(): void + { + $events = DynamicContentSubscriber::getSubscribedEvents(); + + $this->assertArrayHasKey(DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE, $events); + $this->assertSame(['evaluateFilters', 0], $events[DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE]); + } + + public function testEvaluateFiltersSkipsWhenEventAlreadyEvaluated(): void { - $this->configProviderMock->expects($this->once()) + $event = $this->buildEvent(); + $event->setIsEvaluated(true); + + // pluginIsEnabled must never be called — early exit on isEvaluated() + $this->configProvider->expects($this->never())->method('pluginIsEnabled'); + $this->queryFilterFactory->expects($this->never())->method('configureQueryBuilderFromSegmentFilter'); + $this->contactFilterMatcher->expects($this->never())->method('match'); + + $this->subscriber->evaluateFilters($event); + } + + public function testEvaluateFiltersSkipsWhenPluginDisabled(): void + { + $this->configProvider->expects($this->once()) ->method('pluginIsEnabled') ->willReturn(false); $this->queryFilterFactory->expects($this->never())->method('configureQueryBuilderFromSegmentFilter'); + $this->contactFilterMatcher->expects($this->never())->method('match'); - $this->dynamicContentSubscriber->evaluateFilters($this->buildEventWithFilters()); + $this->subscriber->evaluateFilters($this->buildEvent()); } - public function testFiltersNotEvaluatedIfEventMarkedEvaluated(): void + public function testEvaluateFiltersSkipsWhenNoCustomObjectFiltersFound(): void { - $this->configProviderMock->expects($this->once())->method('pluginIsEnabled')->willReturn(true); + $this->configProvider->expects($this->once()) + ->method('pluginIsEnabled') + ->willReturn(true); - $event = $this->buildEventWithFilters(); - $event->setIsEvaluated(true); + // All filters throw → no custom object filters present + $this->queryFilterFactory->expects($this->exactly(2)) + ->method('configureQueryBuilderFromSegmentFilter') + ->willThrowException(new InvalidSegmentFilterException('not a CO filter')); - $this->queryFilterFactory->expects($this->never())->method('configureQueryBuilderFromSegmentFilter'); + $this->contactFilterMatcher->expects($this->never())->method('match'); - $this->dynamicContentSubscriber->evaluateFilters($event); + $event = $this->buildEvent(); + $this->subscriber->evaluateFilters($event); + + $this->assertFalse($event->isEvaluated()); } - public function testFiltersInsertedIntoEvent(): void + public function testEvaluateFiltersMatchesAndSetsResultOnEvent(): void { - $this->configProviderMock->expects($this->once())->method('pluginIsEnabled')->willReturn(true); + $contact = new Lead(); + $contact->setFields(['email' => 'test@example.com']); + + $event = new ContactFiltersEvaluateEvent($this->buildFilters(), $contact); + $this->configProvider->expects($this->once()) + ->method('pluginIsEnabled') + ->willReturn(true); + + // First filter throws (not a CO filter), second succeeds → hasCustomObjectFilters returns true $this->queryFilterFactory->expects($this->exactly(2)) ->method('configureQueryBuilderFromSegmentFilter') - ->withConsecutive( - [ - [ - 'type' => CustomFieldFilterQueryBuilder::getServiceId(), - 'table' => 'custom_field_text', - 'field' => 'cfwq_1', - 'foreign_table' => 'custom_objects', - ], - 'filter_custom_field_1', - ], - [ - [ - 'type' => CustomItemNameFilterQueryBuilder::getServiceId(), - 'table' => 'custom_field_text', - 'field' => 'cowq_2', - 'foreign_table' => 'custom_objects', - ], - 'filter_custom_item_1', - ] - ) ->will($this->onConsecutiveCalls( - $this->queryBuilderMock, - $this->throwException(new InvalidSegmentFilterException('Testing invalid segment handling here.')) + $this->throwException(new InvalidSegmentFilterException('not a CO filter')), + $this->returnValue(null), )); - $event = $this->buildEventWithFilters(); - $event->setIsEvaluated(false); + $this->contactFilterMatcher->expects($this->once()) + ->method('match') + ->with($this->buildFilters(), ['id' => null, 'email' => 'test@example.com']) + ->willReturn(true); + + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertTrue($event->isMatched()); + } + + public function testEvaluateFiltersSetsMismatchOnEvent(): void + { + $contact = new Lead(); + $contact->setFields([]); + + $event = new ContactFiltersEvaluateEvent($this->buildFilters(), $contact); - $result = $this->createMock(Result::class); + $this->configProvider->method('pluginIsEnabled')->willReturn(true); - $this->queryBuilderMock->expects($this->once()) - ->method('execute') - ->willReturn($result); + // First filter is a valid CO filter → hasCustomObjectFilters returns true immediately + $this->queryFilterFactory->expects($this->once()) + ->method('configureQueryBuilderFromSegmentFilter') + ->willReturn(null); - $this->loggerMock - ->expects($this->never()) - ->method('error'); + $this->contactFilterMatcher->expects($this->once()) + ->method('match') + ->willReturn(false); - $this->dynamicContentSubscriber->evaluateFilters($event); + $this->subscriber->evaluateFilters($event); + + $this->assertTrue($event->isEvaluated()); + $this->assertFalse($event->isMatched()); } - private function buildEventWithFilters(): ContactFiltersEvaluateEvent + /** + * @return mixed[] + */ + private function buildFilters(): array { - return new ContactFiltersEvaluateEvent( - [ - 'custom_field_1' => [ - 'type' => CustomFieldFilterQueryBuilder::getServiceId(), - 'table' => 'custom_field_text', - 'field' => 'cfwq_1', - 'foreign_table' => 'custom_objects', - ], - 'custom_item_1' => [ - 'type' => CustomItemNameFilterQueryBuilder::getServiceId(), - 'table' => 'custom_field_text', - 'field' => 'cowq_2', - 'foreign_table' => 'custom_objects', - ], + return [ + 'custom_field_1' => [ + 'type' => CustomFieldFilterQueryBuilder::getServiceId(), + 'table' => 'custom_field_text', + 'field' => 'cfwq_1', + 'foreign_table' => 'custom_objects', ], - new Lead() - ); + 'custom_item_1' => [ + 'type' => CustomItemNameFilterQueryBuilder::getServiceId(), + 'table' => 'custom_field_text', + 'field' => 'cowq_2', + 'foreign_table' => 'custom_objects', + ], + ]; + } + + private function buildEvent(): ContactFiltersEvaluateEvent + { + return new ContactFiltersEvaluateEvent($this->buildFilters(), new Lead()); } } diff --git a/Tests/Unit/EventListener/PluginSchemaSubscriberTest.php b/Tests/Unit/EventListener/PluginSchemaSubscriberTest.php new file mode 100644 index 000000000..499d6b858 --- /dev/null +++ b/Tests/Unit/EventListener/PluginSchemaSubscriberTest.php @@ -0,0 +1,92 @@ +assertArrayHasKey(PluginEvents::ON_PLUGIN_INSTALL, $events); + $this->assertArrayHasKey(PluginEvents::ON_PLUGIN_UPDATE, $events); + } + + public function testInstallCreatesTablesForCustomObjects(): void + { + $installer = $this->createInstallerSpy(); + $subscriber = new PluginSchemaSubscriber($installer); + + $subscriber->onPluginInstall(new PluginInstallEvent($this->createPlugin('Custom Objects'))); + + $this->assertSame(1, $installer->calls); + } + + public function testInstallIgnoresOtherPlugins(): void + { + $installer = $this->createInstallerSpy(); + $subscriber = new PluginSchemaSubscriber($installer); + + $subscriber->onPluginInstall(new PluginInstallEvent($this->createPlugin('Some Other Plugin'))); + + $this->assertSame(0, $installer->calls); + } + + public function testUpdateCreatesMissingTablesForCustomObjects(): void + { + $installer = $this->createInstallerSpy(); + $subscriber = new PluginSchemaSubscriber($installer); + + $subscriber->onPluginUpdate(new PluginUpdateEvent($this->createPlugin('Custom Objects'), '1.0.0')); + + $this->assertSame(1, $installer->calls); + } + + public function testUpdateIgnoresOtherPlugins(): void + { + $installer = $this->createInstallerSpy(); + $subscriber = new PluginSchemaSubscriber($installer); + + $subscriber->onPluginUpdate(new PluginUpdateEvent($this->createPlugin('Some Other Plugin'), '1.0.0')); + + $this->assertSame(0, $installer->calls); + } + + private function createPlugin(string $name): Plugin + { + $plugin = new Plugin(); + $plugin->setName($name); + + return $plugin; + } + + /** + * Counts calls without touching Doctrine. The schema work itself is covered by + * PluginSchemaInstaller against a real database. + */ + private function createInstallerSpy(): PluginSchemaInstaller + { + return new class extends PluginSchemaInstaller { + public int $calls = 0; + + public function __construct() + { + } + + public function ensureTablesExist(): void + { + ++$this->calls; + } + }; + } +} diff --git a/Tests/Unit/EventListener/TokenSubscriberTest.php b/Tests/Unit/EventListener/TokenSubscriberTest.php index 296d881f7..775483ab5 100644 --- a/Tests/Unit/EventListener/TokenSubscriberTest.php +++ b/Tests/Unit/EventListener/TokenSubscriberTest.php @@ -27,6 +27,7 @@ use MauticPlugin\CustomObjectsBundle\Event\CustomItemListDbalQueryEvent; use MauticPlugin\CustomObjectsBundle\EventListener\TokenSubscriber; use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException; +use MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator; use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper; use MauticPlugin\CustomObjectsBundle\Helper\TokenFormatter; use MauticPlugin\CustomObjectsBundle\Helper\TokenParser; @@ -92,6 +93,11 @@ class TokenSubscriberTest extends TestCase */ private $tokenFormatter; + /** + * @var FilterEvaluator|MockObject + */ + private $filterEvaluator; + /** * @var TokenSubscriber */ @@ -126,6 +132,7 @@ protected function setUp(): void $this->eventModel = $this->createMock(EventModel::class); $this->eventDispatcher = $this->createMock(EventDispatcher::class); $this->tokenFormatter = $this->createMock(TokenFormatter::class); + $this->filterEvaluator = $this->createMock(FilterEvaluator::class); $this->subscriber = new TokenSubscriber( $this->configProvider, $this->queryFilterHelper, @@ -137,6 +144,7 @@ protected function setUp(): void $this->eventModel, $this->eventDispatcher, $this->tokenFormatter, + $this->filterEvaluator, 15 ); @@ -860,6 +868,7 @@ private function constructWithDependencies(): void $this->eventModel, $this->eventDispatcher, new TokenFormatter(), + $this->createMock(FilterEvaluator::class), 15 ); } diff --git a/Tests/Unit/Helper/ContactFilterMatcherTest.php b/Tests/Unit/Helper/ContactFilterMatcherTest.php new file mode 100644 index 000000000..eef34aee4 --- /dev/null +++ b/Tests/Unit/Helper/ContactFilterMatcherTest.php @@ -0,0 +1,309 @@ +customFieldModel = $this->createMock(CustomFieldModel::class); + $this->customObjectModel = $this->createMock(CustomObjectModel::class); + $this->customItemModel = $this->createMock(CustomItemModel::class); + $this->companyRepository = $this->createMock(CompanyRepository::class); + $this->connection = $this->createMock(Connection::class); + + $this->matcher = new ContactFilterMatcher( + $this->customFieldModel, + $this->customObjectModel, + $this->customItemModel, + $this->companyRepository, + $this->connection, + new FilterEvaluator(), + 10 + ); + } + + public function testMatchReturnsFalseWhenNoCustomObjectFiltersPresent(): void + { + $filters = [ + $this->buildLeadFilter('email', '=', 'test@example.com'), + ]; + + $hasCustomFields = false; + $result = $this->matcher->match($filters, ['id' => 1, 'email' => 'test@example.com'], $hasCustomFields); + + $this->assertFalse($result); + $this->assertFalse($hasCustomFields); + $this->customObjectModel->expects($this->never())->method('fetchEntity'); + } + + public function testMatchReturnsFalseWhenCustomObjectFetchThrowsNotFoundException(): void + { + $this->customObjectModel->method('fetchEntity') + ->willThrowException(new NotFoundException('Custom object not found')); + + $hasCustomFields = false; + $result = $this->matcher->match([$this->buildCmoFilter('cmo_1', '=', 'Acme')], ['id' => 42], $hasCustomFields); + + $this->assertFalse($result); + $this->assertFalse($hasCustomFields); + } + + public function testMatchReturnsFalseWhenCustomObjectFetchThrowsInvalidCustomObjectFormatListException(): void + { + $this->customObjectModel->method('fetchEntity') + ->willThrowException(new InvalidCustomObjectFormatListException('bad format')); + + $hasCustomFields = false; + $result = $this->matcher->match([$this->buildCmoFilter('cmo_1', '=', 'Acme')], ['id' => 42], $hasCustomFields); + + $this->assertFalse($result); + $this->assertFalse($hasCustomFields); + } + + public function testMatchReturnsFalseWhenContactHasNoLinkedCustomItems(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(5)); + $this->customItemModel->method('getArrayTableData')->willReturn([]); + + $hasCustomFields = false; + $result = $this->matcher->match([$this->buildCmoFilter('cmo_5', '=', 'Acme')], ['id' => 42], $hasCustomFields); + + $this->assertFalse($result); + // hasCustomFields is true because the CO filter was found and processed + $this->assertTrue($hasCustomFields); + } + + public function testMatchReturnsTrueForEmptyOperatorWhenContactHasNoLinkedItems(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([]); + + $filter = array_merge($this->buildCmoFilter('cmo_1', 'empty', ''), ['type' => 'text']); + $result = $this->matcher->match([$filter], ['id' => 42]); + + $this->assertTrue($result); + } + + public function testMatchReturnsFalseForNotEmptyOperatorWhenContactHasNoLinkedItems(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([]); + + $filter = array_merge($this->buildCmoFilter('cmo_1', '!empty', ''), ['type' => 'text']); + $result = $this->matcher->match([$filter], ['id' => 42]); + + $this->assertFalse($result); + } + + public function testMatchReturnsTrueWhenItemNameMatchesEqualFilter(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(3)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Acme Corp']]); + + $result = $this->matcher->match([$this->buildCmoFilter('cmo_3', '=', 'Acme Corp')], ['id' => 42]); + + $this->assertTrue($result); + } + + public function testMatchReturnsFalseWhenItemNameDoesNotMatchEqualFilter(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(3)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Wrong Corp']]); + + $result = $this->matcher->match([$this->buildCmoFilter('cmo_3', '=', 'Acme Corp')], ['id' => 42]); + + $this->assertFalse($result); + } + + public function testMatchReturnsTrueWhenAnyLinkedItemNameMatchesFilter(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(3)); + $this->customItemModel->method('getArrayTableData')->willReturn([ + ['name' => 'First Item'], + ['name' => 'Acme Corp'], + ['name' => 'Third Item'], + ]); + + $result = $this->matcher->match([$this->buildCmoFilter('cmo_3', '=', 'Acme Corp')], ['id' => 42]); + + $this->assertTrue($result); + } + + public function testMatchSetsHasCustomFieldsToTrueWhenCustomObjectFilterIsProcessed(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Any']]); + + $hasCustomFields = false; + $this->matcher->match([$this->buildCmoFilter('cmo_1', '=', 'Any')], ['id' => 42], $hasCustomFields); + + $this->assertTrue($hasCustomFields); + } + + public function testCustomItemsAreFetchedOncePerObjectAndLeadAcrossMultipleFilters(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + + $this->customItemModel->expects($this->once()) + ->method('getArrayTableData') + ->willReturn([['name' => 'Acme']]); + + $filters = [ + $this->buildCmoFilter('cmo_1', '=', 'Acme'), + $this->buildCmoFilter('cmo_1', '!=', 'Other'), + ]; + + $this->matcher->match($filters, ['id' => 42]); + } + + public function testMatchFetchesCompanyDataWhenFilterFieldStartsWithCompany(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Test']]); + + $this->companyRepository->expects($this->once()) + ->method('getCompaniesByLeadId') + ->with('42') + ->willReturn([]); + + $filters = [ + $this->buildCmoFilter('cmo_1', '=', 'Test'), + $this->buildLeadFilter('companycountry', '=', 'US'), + ]; + + $this->matcher->match($filters, ['id' => 42]); + } + + public function testMatchDoesNotFetchCompanyDataWhenLeadAlreadyHasCompanies(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Test']]); + + $this->companyRepository->expects($this->never())->method('getCompaniesByLeadId'); + + $filters = [ + $this->buildCmoFilter('cmo_1', '=', 'Test'), + $this->buildLeadFilter('companycountry', '=', 'US'), + ]; + + $this->matcher->match($filters, ['id' => 42, 'companies' => [['companycountry' => 'US']]]); + } + + public function testMatchFetchesTagsWhenFilterTypeIsTags(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Test']]); + + $result = $this->createMock(Result::class); + $queryBuilder = $this->createMock(DbalQueryBuilder::class); + $result->method('fetchFirstColumn')->willReturn(['1', '5']); + $queryBuilder->method('select')->willReturnSelf(); + $queryBuilder->method('from')->willReturnSelf(); + $queryBuilder->method('where')->willReturnSelf(); + $queryBuilder->method('setParameter')->willReturnSelf(); + $queryBuilder->method('executeQuery')->willReturn($result); + + $this->connection->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + + $filters = [ + $this->buildCmoFilter('cmo_1', '=', 'Test'), + ['object' => 'lead', 'field' => 'tags', 'type' => 'tags', 'operator' => 'in', 'filter' => ['1'], 'glue' => 'and'], + ]; + + $this->matcher->match($filters, ['id' => 42]); + } + + public function testMatchDoesNotFetchTagsWhenLeadAlreadyHasTags(): void + { + $this->customObjectModel->method('fetchEntity')->willReturn($this->buildCustomObject(1)); + $this->customItemModel->method('getArrayTableData')->willReturn([['name' => 'Test']]); + + $this->connection->expects($this->never())->method('createQueryBuilder'); + + $filters = [ + $this->buildCmoFilter('cmo_1', '=', 'Test'), + ['object' => 'lead', 'field' => 'tags', 'type' => 'tags', 'operator' => 'in', 'filter' => ['1'], 'glue' => 'and'], + ]; + + $this->matcher->match($filters, ['id' => 42, 'tags' => ['1', '5']]); + } + + // Helpers + + private function buildCustomObject(int $id): MockObject + { + $customObject = $this->createMock(CustomObject::class); + $customObject->method('getId')->willReturn($id); + + return $customObject; + } + + /** + * @return mixed[] + */ + private function buildCmoFilter(string $field, string $operator, string $filterValue): array + { + return [ + 'object' => 'custom_object', + 'field' => $field, + 'type' => 'text', + 'operator' => $operator, + 'filter' => $filterValue, + 'glue' => 'and', + ]; + } + + /** + * @return mixed[] + */ + private function buildLeadFilter(string $field, string $operator, string $filterValue): array + { + return [ + 'object' => 'lead', + 'field' => $field, + 'type' => 'text', + 'operator' => $operator, + 'filter' => $filterValue, + 'glue' => 'and', + ]; + } +} diff --git a/Tests/Unit/Helper/FilterEvaluatorTest.php b/Tests/Unit/Helper/FilterEvaluatorTest.php new file mode 100644 index 000000000..8dfcf4b5d --- /dev/null +++ b/Tests/Unit/Helper/FilterEvaluatorTest.php @@ -0,0 +1,364 @@ +evaluator = new FilterEvaluator(); + } + + // ------------------------------------------------------------------------- + // Guard conditions + // ------------------------------------------------------------------------- + + public function testReturnsFalseWhenLeadHasNoId(): void + { + $filters = [$this->leadFilter('email', 'text', '=', 'test@example.com')]; + $this->assertFalse($this->evaluator->evaluate($filters, ['email' => 'test@example.com'])); + } + + public function testReturnsFalseWhenFiltersAreEmpty(): void + { + $this->assertFalse($this->evaluator->evaluate([], ['id' => 1])); + } + + public function testSkipsFilterWhenFieldNotPresentInLead(): void + { + // Filter references a field that is not in $lead — should be skipped, result is false. + $filters = [$this->leadFilter('missing_field', 'text', '=', 'value')]; + $this->assertFalse($this->evaluator->evaluate($filters, ['id' => 1])); + } + + // ------------------------------------------------------------------------- + // AND / OR group logic + // ------------------------------------------------------------------------- + + public function testAndFiltersAllMustBeTrue(): void + { + $filters = [ + $this->leadFilter('first_name', 'text', '=', 'Alice'), + $this->leadFilter('last_name', 'text', '=', 'Smith'), + ]; + + $this->assertTrue( + $this->evaluator->evaluate($filters, ['id' => 1, 'first_name' => 'Alice', 'last_name' => 'Smith']) + ); + + $this->assertFalse( + $this->evaluator->evaluate($filters, ['id' => 1, 'first_name' => 'Alice', 'last_name' => 'Jones']) + ); + } + + public function testOrGroupPassesWhenFirstGroupFails(): void + { + $filters = [ + $this->leadFilter('email', 'text', '=', 'wrong@example.com'), + array_merge($this->leadFilter('city', 'text', '=', 'Paris'), ['glue' => 'or']), + ]; + + $lead = ['id' => 1, 'email' => 'test@example.com', 'city' => 'Paris']; + $this->assertTrue($this->evaluator->evaluate($filters, $lead)); + } + + public function testOrGroupPassesWhenFirstGroupPasses(): void + { + $filters = [ + $this->leadFilter('email', 'text', '=', 'test@example.com'), + array_merge($this->leadFilter('city', 'text', '=', 'Wrong'), ['glue' => 'or']), + ]; + + $lead = ['id' => 1, 'email' => 'test@example.com', 'city' => 'Paris']; + $this->assertTrue($this->evaluator->evaluate($filters, $lead)); + } + + public function testReturnsFalseWhenAllOrGroupsFail(): void + { + $filters = [ + $this->leadFilter('email', 'text', '=', 'wrong@example.com'), + array_merge($this->leadFilter('city', 'text', '=', 'Wrong'), ['glue' => 'or']), + ]; + + $lead = ['id' => 1, 'email' => 'test@example.com', 'city' => 'Paris']; + $this->assertFalse($this->evaluator->evaluate($filters, $lead)); + } + + // ------------------------------------------------------------------------- + // Custom object any-match semantics + // ------------------------------------------------------------------------- + + public function testCustomObjectAnyItemMatchWins(): void + { + $filters = [$this->cmoFilter('cmf_1', 'text', '=', 'premium')]; + $lead = ['id' => 1, 'cmf_1' => ['basic', 'premium', 'trial']]; + + $this->assertTrue($this->evaluator->evaluate($filters, $lead)); + } + + public function testCustomObjectReturnsFalseWhenNoItemMatches(): void + { + $filters = [$this->cmoFilter('cmf_1', 'text', '=', 'enterprise')]; + $lead = ['id' => 1, 'cmf_1' => ['basic', 'premium']]; + + $this->assertFalse($this->evaluator->evaluate($filters, $lead)); + } + + public function testCustomObjectEmptyOperatorMatchesWhenNoLinkedItems(): void + { + $filters = [$this->cmoFilter('cmf_1', 'text', 'empty', null)]; + $lead = ['id' => 1, 'cmf_1' => []]; + + $this->assertTrue($this->evaluator->evaluate($filters, $lead)); + } + + public function testCustomObjectNotEmptyOperatorFailsWhenNoLinkedItems(): void + { + $filters = [$this->cmoFilter('cmf_1', 'text', '!empty', null)]; + $lead = ['id' => 1, 'cmf_1' => []]; + + $this->assertFalse($this->evaluator->evaluate($filters, $lead)); + } + + public function testCustomObjectScalarValueIsWrappedInArray(): void + { + // cmf_ value stored as scalar rather than array — evaluator must normalise it. + $filters = [$this->cmoFilter('cmf_1', 'text', '=', 'hello')]; + $lead = ['id' => 1, 'cmf_1' => 'hello']; + + $this->assertTrue($this->evaluator->evaluate($filters, $lead)); + } + + // ------------------------------------------------------------------------- + // Operators — string/text + // ------------------------------------------------------------------------- + + public function testEqualOperator(): void + { + $this->assertOperator('text', '=', 'abc', 'abc', true); + $this->assertOperator('text', '=', 'abc', 'xyz', false); + } + + public function testNotEqualOperator(): void + { + $this->assertOperator('text', '!=', 'abc', 'xyz', true); + $this->assertOperator('text', '!=', 'abc', 'abc', false); + } + + public function testEmptyOperator(): void + { + $this->assertOperator('text', 'empty', '', null, true); + $this->assertOperator('text', 'empty', 'value', null, false); + } + + public function testNotEmptyOperator(): void + { + $this->assertOperator('text', '!empty', 'value', null, true); + $this->assertOperator('text', '!empty', '', null, false); + } + + public function testLikeOperatorWithPercentWildcard(): void + { + $this->assertOperator('text', 'like', 'abracadabra', 'abra%', true); + $this->assertOperator('text', 'like', 'abracadabra', '%cadabra', true); + $this->assertOperator('text', 'like', 'abracadabra', '%cada%', true); + $this->assertOperator('text', 'like', 'abracadabra', 'unicorn%', false); + } + + public function testNotLikeOperator(): void + { + $this->assertOperator('text', '!like', 'abracadabra', 'unicorn%', true); + $this->assertOperator('text', '!like', 'abracadabra', 'abra%', false); + } + + public function testStartsWithOperator(): void + { + $this->assertOperator('text', 'startsWith', 'abracadabra', 'abra', true); + $this->assertOperator('text', 'startsWith', 'abracadabra', 'cadabra', false); + } + + public function testEndsWithOperator(): void + { + $this->assertOperator('text', 'endsWith', 'abracadabra', 'cadabra', true); + $this->assertOperator('text', 'endsWith', 'abracadabra', 'unicorn', false); + } + + public function testContainsOperator(): void + { + $this->assertOperator('text', 'contains', 'abracadabra', 'cada', true); + $this->assertOperator('text', 'contains', 'abracadabra', 'unicorn', false); + } + + public function testRegexpOperator(): void + { + $this->assertOperator('text', 'regexp', 'abracadabra', 'abra.*cadabra', true); + $this->assertOperator('text', 'regexp', 'abracadabra', '^unicorn', false); + // Regexp is case-insensitive + $this->assertOperator('text', 'regexp', 'HELLO', 'hello', true); + } + + public function testNotRegexpOperator(): void + { + $this->assertOperator('text', '!regexp', 'abracadabra', '^unicorn', true); + $this->assertOperator('text', '!regexp', 'abracadabra', 'abra.*cadabra', false); + } + + public function testInOperator(): void + { + $this->assertOperator('text', 'in', 'b', ['a', 'b', 'c'], true); + $this->assertOperator('text', 'in', 'z', ['a', 'b', 'c'], false); + } + + public function testNotInOperator(): void + { + $this->assertOperator('text', '!in', 'z', ['a', 'b', 'c'], true); + $this->assertOperator('text', '!in', 'b', ['a', 'b', 'c'], false); + } + + public function testUnknownOperatorThrowsException(): void + { + $this->expectException(OperatorsNotFoundException::class); + + $filters = [$this->leadFilter('email', 'text', 'nonexistent_operator', 'value')]; + $this->evaluator->evaluate($filters, ['id' => 1, 'email' => 'test@example.com']); + } + + // ------------------------------------------------------------------------- + // Operators — comparison (number type) + // ------------------------------------------------------------------------- + + public function testGtOperator(): void + { + $this->assertOperator('number', 'gt', 10, 5, true); + $this->assertOperator('number', 'gt', 5, 10, false); + $this->assertOperator('number', 'gt', 5, 5, false); + } + + public function testGteOperator(): void + { + $this->assertOperator('number', 'gte', 10, 5, true); + $this->assertOperator('number', 'gte', 5, 5, true); + $this->assertOperator('number', 'gte', 4, 5, false); + } + + public function testLtOperator(): void + { + $this->assertOperator('number', 'lt', 3, 5, true); + $this->assertOperator('number', 'lt', 5, 5, false); + $this->assertOperator('number', 'lt', 10, 5, false); + } + + public function testLteOperator(): void + { + $this->assertOperator('number', 'lte', 3, 5, true); + $this->assertOperator('number', 'lte', 5, 5, true); + $this->assertOperator('number', 'lte', 10, 5, false); + } + + // ------------------------------------------------------------------------- + // Type coercions + // ------------------------------------------------------------------------- + + public function testBooleanCoercionEqualTrueValues(): void + { + // String '1' and int 1 are both coerced to true before comparison. + $this->assertOperator('boolean', '=', '1', 1, true); + $this->assertOperator('boolean', '=', '0', 0, true); + $this->assertOperator('boolean', '=', '1', 0, false); + } + + public function testBooleanStrictNotEqualUsesIdentity(): void + { + // For boolean type != uses strict identity after coercion. + $this->assertOperator('boolean', '!=', '1', 0, true); + $this->assertOperator('boolean', '!=', '0', 0, false); + } + + public function testMultiselectCoercionSplitsByPipe(): void + { + // Pipe-separated string is split into an array before in-check. + $this->assertOperator('multiselect', 'in', 'a|b|c', ['b'], true); + $this->assertOperator('multiselect', 'in', 'a|b|c', ['z'], false); + } + + public function testSelectCoercionSplitsByPipe(): void + { + $this->assertOperator('select', 'in', 'a|b', ['a'], true); + } + + public function testDatetimeCoercionAppendsMissingSeconds(): void + { + // Lead value has H:i:s, filter only H:i — evaluator appends :00 to filter. + $this->assertOperator('datetime', '=', '2024-01-01 12:00:00', '2024-01-01 12:00', true); + } + + public function testNumberCoercionConvertsToInt(): void + { + // String '42' in lead and int 42 as filter — both coerced to int. + $this->assertOperator('number', '=', '42', 42, true); + // 'int' is the custom field type key — same coercion applies. + $this->assertOperator('int', '=', '42', 42, true); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * @param mixed $leadValue + * @param mixed $filterValue + */ + private function assertOperator(string $type, string $operator, $leadValue, $filterValue, bool $expected): void + { + $filters = [$this->leadFilter('field', $type, $operator, $filterValue)]; + $result = $this->evaluator->evaluate($filters, ['id' => 1, 'field' => $leadValue]); + + $this->assertSame( + $expected, + $result, + "Operator '{$operator}' (type: {$type}): lead=".json_encode($leadValue).' filter='.json_encode($filterValue) + ); + } + + /** + * @param mixed $filterValue + * + * @return array + */ + private function leadFilter(string $field, string $type, string $operator, $filterValue): array + { + return [ + 'glue' => 'and', + 'field' => $field, + 'object' => 'lead', + 'type' => $type, + 'filter' => $filterValue, + 'operator' => $operator, + ]; + } + + /** + * @param mixed $filterValue + * + * @return array + */ + private function cmoFilter(string $field, string $type, string $operator, $filterValue): array + { + return [ + 'glue' => 'and', + 'field' => $field, + 'object' => 'custom_object', + 'type' => $type, + 'filter' => $filterValue, + 'operator' => $operator, + ]; + } +} diff --git a/docs/superpowers/reports/2026-07-29-mautic6-official-verify-findings.md b/docs/superpowers/reports/2026-07-29-mautic6-official-verify-findings.md new file mode 100644 index 000000000..69c8890fa --- /dev/null +++ b/docs/superpowers/reports/2026-07-29-mautic6-official-verify-findings.md @@ -0,0 +1,106 @@ +# Mautic 6 Official Instance Verification — Findings + +**Date:** 2026-07-29 +**Plugin branch tested:** 6.x (via chore/mautic6-official-verify) +**Mautic version:** 6.0.9 (official upstream, unmodified) + +## Result + +PASS WITH FIXES — the plugin's core CRUD, Segment filtering, and Campaign conditions all work end-to-end against a fresh, official Mautic 6.0.9 instance. Its unit test suite (618 tests) passes in full under Symfony 6.4, and its functional test suite (87 tests) went from 44 errors/3 failures to 6 errors/3 failures, with every remaining failure root-caused and documented (a Mautic-core-level dependency issue and a Symfony 6 session/CSRF testing gap — neither a plugin bug). + +## Success criteria + +| Criterion | Result | Notes | +|---|---|---| +| cache:clear / cache:warmup clean | ✅ | Clean run against fresh 6.0.9, no plugin changes needed. | +| mautic:plugins:reload installs schema | ✅ | CustomObjectsBundle registered; all DB tables confirmed present via direct DB inspection. | +| Admin UI loads | ✅ | Login and dashboard render correctly. | +| Custom Objects list renders | ✅ | List view and left-nav item render correctly after creating a Custom Object. | +| Create Custom Object + Custom Item works | ✅ | Custom Object "Product" (with a Description field) created cleanly on first attempt. Custom Item creation initially failed with HTTP 400 (see Fixes applied) and passes after the two fixes below — verified end-to-end: create → Save → edit view (200) → Save & Close → detail view (200), DB row persisted, zero `mautic.ERROR`/`mautic.CRITICAL` log entries during the window. | + +## Headline features (beyond the original 5 criteria) + +Tested after the initial pass, since these are the plugin's actual value proposition (per its own README) and hadn't been exercised yet: + +| Feature | Result | Notes | +|---|---|---| +| Segment filtering by Custom Object field values | ✅ | Worked correctly on the first attempt, no fix needed. A segment filtered on "Products : Description contains Blue" correctly included a contact linked to a matching item and excluded one linked to a non-matching item, verified via `mautic:segments:update` + UI membership check. | +| Campaign condition on Custom Object field values | ✅ (after fix) | Initially broken: opening the "Product field value" condition's config form threw a 500 (`Twig\Error\RuntimeError: Neither the property "form" ... exist`). Fixed (see Fixes applied). After the fix: a campaign condition correctly routed a matching contact down the Yes branch and a non-matching contact down the No branch, verified via `mautic:campaigns:trigger` + tag checks on both contacts. | + +## Fixes applied + +Both commits are on branch `chore/mautic6-official-verify` (built off `6.x`) in the plugin repo: + +- **`d2105e6f`** — `fix(CustomItem): use InputBag::all() for array-shaped custom_item field` + Fixed `Controller/CustomItem/SaveController.php:37`: `$request->request->get('custom_item')` was called on an array-shaped form field. Symfony 6.4's `InputBag::get()` rejects non-scalar values and threw, producing an HTTP 400 ("Input value \"custom_item\" contains a non-scalar value") on every Custom Item save. Changed to `InputBag::all('custom_item')`, which is the correct accessor for array-shaped request parameters. + +- **`08ef6cc7`** — `fix(CustomItem): use double-colon controller syntax in SaveController::forward()` + Fixed one `$this->forward(...)` call site in the same file, fed by three string-literal assignments (`$detailView`, and two `$formView` variants) all using the legacy single-colon `Class:method` controller-reference syntax. Symfony's `ControllerResolver::createController()` in this Symfony version only accepts the double-colon `Class::method` syntax; the single-colon form throws a loud `InvalidArgumentException` (`Controller "...ClassName:methodName" does neither exist as service nor as class.`) rather than failing silently — this was a second, previously-masked failure that surfaced only once the first bug (`d2105e6f`) was fixed, since the array-value crash happened earlier in the same method. + +## Environment-only notes (not plugin bugs, no code changes made) + +These were observed during setup/testing and are recorded here for completeness, since they affect anyone else re-running this verification on the same host or a similarly fresh instance: + +- **mkcert / HTTPS gap (host tooling):** `mkcert` is not installed on this host, so DDEV's router never generates a valid TLS certificate for the throwaway `mautic-6-official` instance (empty/corrupt PEM). Worked around by switching the test instance's `site_url` to plain HTTP (`http://mautic-6-official.ddev.site:8080`); no system-level changes were made. This will block anyone testing HTTPS-dependent behavior on this host until `mkcert` is installed — unrelated to the plugin. +- **Doctrine migrations metadata-storage table (core Mautic install-flow quirk):** on this fresh Mautic 6.0.9 install, core Mautic's own `doctrine:migrations:version` metadata-storage table was never initialized (confirmed via `InstallService::finalMigrationStep()`). This didn't block anything tested in this plan, but any future `doctrine:migrations:migrate` call on this instance would need `doctrine:migrations:sync-metadata-storage` run first. Not a CustomObjectsBundle concern. +- **redis-commander DDEV addon (host/infra):** `ddev start` initially failed because the optional `redis-commander` DDEV addon couldn't pull its image (GHCR registry pull denial). Worked around by disabling the addon (renamed to `.disabled`) in the throwaway `mautic-6-official` worktree — reversible, unrelated to the plugin. +- **Sibling single-colon controller-string bug — now fixed.** The single-colon `Class:method` pattern fixed in commit `08ef6cc7` (see above) also existed in `Controller/CustomObject/DeleteController.php:30` (feeding both `forward()` calls in that file, at lines 57 and 67) and `Controller/CustomObject/CancelController.php:32`. Neither was on the flow tested in the original pass (create Custom Object / create Custom Item), so neither was fixed at the time — it was flagged here as a known-issue class instead. A follow-up pass (commit `d4c895f3` on `chore/mautic6-official-verify`) applied the same double-colon fix to both files; a repo-wide grep confirmed no other single-colon controller-reference strings remain in `Controller/`. + + Worth recording for the historical record: `CancelController.php`'s failure mode was not identical to `SaveController.php`'s. `CancelController` extends Mautic core's `CommonController::postActionRedirect()`, which only calls `forward()` on XHR requests (Mautic's normal cancel path uses XHR). With the single-colon bug in place, this surfaced as an uncaught `InvalidArgumentException` resulting in HTTP 500 — not the HTTP 400 seen for the `SaveController` bug, which was actually a different, unrelated `InputBag` bug (`d2105e6f`), not this one. That distinction is now moot in practice since both files are fixed, but it's useful context for understanding what class of Mautic 6 compatibility issue this was. + +- **`1f62fb32`** — `fix(campaign): use plugin's own form theme for field-value condition` + `EventListener/CampaignSubscriber.php` registered the "Product field value" campaign condition with Mautic **core's** form-theme template (`@MauticForm/FormTheme/FieldValueCondition/_campaignevent_form_field_value_widget.html.twig`), which expects a `form.form` property that this plugin's `CampaignConditionFieldValueType` doesn't have (`field`/`operator`/`value` instead) — crashing with a Twig `RuntimeError` every time the condition's config form was opened. The plugin already shipped the correct template at `Resources/views/FormTheme/FieldValueCondition/campaign_condition_field_value_widget.html.twig`; it was just never referenced. Fixed by pointing at it. Verified end-to-end via a real campaign: matching contact → Yes branch, non-matching contact → No branch. + +- **`048d4457`** — `fix(di): correct TokenSubscriber's argument list and missing import` + A genuine **production** bug, not a test-only issue, found while making the unit test suite pass: `TokenSubscriber`'s constructor gained a `CustomFieldModel` parameter and switched its trailing parameter from `ContactFilterMatcher` to a scalar `leadCustomItemFetchLimit` at some point, but `Config/config.php`'s explicit service definition (`custom_object.emailtoken.subscriber`) was never updated to match — still passing the old 10-argument list. This would have thrown a fatal error the first time Mautic actually tried to replace a Custom-Object email token (`{customobject=...}`), since Symfony's explicit-argument services aren't type/count-checked at container compile time, only at instantiation. Also fixed a missing `use` import for `CustomFieldModel` in `TokenSubscriber.php` itself: without it, the constructor's type-hint silently resolved to the current namespace (`EventListener\CustomFieldModel`, which doesn't exist) instead of `Model\CustomFieldModel`, so even a corrected argument list would still have been rejected. Verified by instantiating the service through the real compiled container (`$container->get('custom_object.emailtoken.subscriber')`), not just by fixing the unit test. + +## Unit test suite: now passes in full under Symfony 6.4 + +The original pass through this plugin found the `Tests/Unit` suite did not run to completion under Symfony 6.4 on Mautic 6 and left it as a documented gap. That gap has since been closed: **all 618 unit tests pass** (11 pre-existing, intentional skips unrelated to Symfony 6; 0 errors, 0 failures). + +28 test-double failures were fixed across 8 files, all variants of the same underlying theme — Symfony 6 added or now strictly enforces native return-type declarations (`RequestStack::getCurrentRequest(): ?Request`, `FormInterface::get(): FormInterface`, `HttpKernelInterface::handle(): Response`, `ContainerBuilder::findDefinition(): Definition`, `FormBuilderInterface`/`FormConfigBuilderInterface`) that older hand-rolled or auto-generated test doubles didn't satisfy — plus one unrelated pre-existing test-environment gap (a missing export directory) and the `self::$container` → `static::getContainer()` KernelTestCase migration. Commits, all on `chore/mautic6-official-verify`: + +- **`9fc64c5c`** — `RequestStack` test double missing the `?Request` return type on `getCurrentRequest()`, a fatal error that aborted the entire PHPUnit process before any later test file could run. +- **`8c115565`** — `FormControllerTest`'s `HttpKernelInterface::handle()` mocks returning `null` instead of a real `Response`. +- **`5800a4b0`** — `CustomFieldTypePassTest`'s `ContainerBuilder::findDefinition()` mock supplied only 1 of 3 expected consecutive return values, so PHPUnit filled the gap with `null`. +- **`f8910dcc`** — `CampaignConditionFieldValueTypeTest` mocked the narrower `FormConfigBuilderInterface` where the wider `FormBuilderInterface` (which extends it) was required. +- **`dc8dd84d`** — `SaveControllerTest` (both `CustomItem` and `CustomObject`) mocked `$form->get('buttons')->get('save')` as a bare `ClickableInterface`, but that call resolves to Symfony's real `SubmitButton`, which implements both `ClickableInterface` and `FormInterface`. Added a shared `createClickableFormMock()` helper on `ControllerTestCase` that mocks the concrete `SubmitButton` class instead. +- **`4c84e133`** — `CustomFieldValueTypeTest` mocked `FormBuilderInterface::getName()` to return an `int`; the production code casts it to derive a field ID and expects the numeric-string form real form builders produce. +- **`082d17fd`** — `self::$container` (removed in Symfony 6) → `static::getContainer()`, in the shared `CustomObjectTestCase` base class and its direct test. +- **`41aa0f1b`** — `CustomItemExportSchedulerModel` never created its export directory; harmless if it already existed (e.g. from a prior export), but fatal on a truly fresh install where `@fopen()`'s error-suppression silently returned `false` into `fputcsv()`. Not Symfony-6-specific, just never previously exercised on a fresh instance. Now creates the directory up front if missing. + +Notably, `SaveControllerTest.php` already had coverage that POSTs an array-shaped `custom_item` payload through `saveAction()` (the earlier `d2105e6f` fix) — with the suite now running, this branch's own future regressions in that area would be caught by `phpunit` alone, no browser round-trip needed. + +## Functional test suite: fixed from 44 errors to 6, 87 tests total + +There is a separate, DB-backed `Tests/Functional` suite (87 tests) covering much of the same ground as the manual browser testing above — including `CampaignConditionTest.php` and several Segment filter query-builder tests. It started at 44 errors / 3 failures / 33 skipped (only 7 tests actually passing). After this pass: **6 errors / 3 failures / 42 skipped**, all remaining ones root-caused and left as documented gaps rather than force-fixed (see below) — 38 of the original 44 errors are resolved. + +Fixed (commits on `chore/mautic6-official-verify`): + +- **`95d1c811`** — the same `self::$container` → `static::getContainer()` Symfony 6 migration as `082d17fd`, applied in bulk across 16 more files (37 call sites) — this alone took errors from 44 to 11. +- **`5003213b`** — `Entity/CustomItem.php`: `addCustomFieldValue()` used a `CustomField`'s (possibly-unpersisted, `null`) id directly as an `ArrayCollection` key. PHP's native arrays silently coerce a `null` key to `''`; newer `doctrine/collections` now rejects it with a `TypeError`. Restored the old behavior explicitly with `?? ''`. +- **`a45bba1c`** — a **second** real production bug in `TokenSubscriber`, found only by the functional suite (invisible to `Tests/Unit`, which doesn't exercise `onTokenReplacement()` at all): an earlier commit on this branch (`048d4457`) had correctly matched the constructor to what it declared at the time, but `onTokenReplacement()` still referenced `$this->contactFilterMatcher` — a property a *prior, already-reverted* change had added without the revert cleanly restoring it. Re-added `ContactFilterMatcher` as a 12th constructor parameter (verified via a full-file audit of every `$this->` reference, not just the constructor) and wired it back in `Config/config.php`. +- **`502b3744`** — `CampaignConditionTest.php`: `$this->createAjaxHeaders()` was called but never defined anywhere in the codebase (removed at some point, test never updated) — added it back matching the `HTTP_X-Requested-With` pattern used in `CustomObjectFormTest`. Also fixed `ChoiceFormField::setValue()` rejecting an `int` where a `string|bool|array|null` is now required. + +Left as documented gaps, not fixed (two distinct root causes, one core-level and one deep framework-testing issue): + +- **`beberlei/doctrineextensions` vs `doctrine/lexer` incompatibility (5 of the 6 remaining errors).** This package (last released 2020, effectively abandoned) provides MySQL's `MATCH AGAINST` full-text search as a Doctrine DQL function, and assumes `doctrine/lexer`'s `Token` is an array; this Mautic 6 install's `doctrine/lexer` (3.0.1) made `Token` an object, so every full-text search throws `Cannot use object of type Doctrine\Common\Lexer\Token as array`. **This is a Mautic-core-level issue, not a plugin bug** — grepping the plugin finds no direct reference to `MatchAgainst`/`MATCH_AGAINST` anywhere; it's Mautic core's generic entity-search infrastructure, which this plugin's `CustomItemModel`/`ListController` go through like any other searchable entity. Affects `CustomItemListControllerSearchTest` and `CustomItemLookupControllerTest`. Fixing this would mean patching or replacing a Mautic-core dependency, well outside this plugin's scope. +- **Symfony 6 session/CSRF continuity in functional tests (1 error + all 3 failures).** `CampaignConditionTest::testConditionForm` pre-registers a mock `'session'` container service *before* calling `parent::setUp()`, expecting `MauticMysqlTestCase`'s internal `loginUser()` call to use it — but `MauticMysqlTestCase::setUpSymfony()` calls `self::ensureKernelShutdown()` first, which discards it, and Symfony 6's actual `KernelBrowser::loginUser()` builds its own session via `session.factory` + a cookie, never consulting the `'session'` service id at all. Reordering the calls fixes the immediate `ServiceNotFoundException` but then fails on a CSRF token mismatch instead — the pre-registered session object needs to be the *same instance* `loginUser()` ends up using for the swap-storage trick (visible in the test's own `$session->__construct(new MockArraySessionStorage())` line) to preserve token continuity, and under Symfony 6 it structurally isn't. `CustomObjectFormTest`'s 3 failures are very likely the same root cause by symptom (POSTing a save request gets back a login-page-wrapped AJAX envelope instead of the expected redirect, and `testParametersCreateEdit` explicitly shows `'/s/login'` where `/s/custom/object/edit/1'` was expected) but weren't traced to the same level of detail. This needs a proper Symfony 6 functional-test session/CSRF migration — a real, if narrow, piece of work — not a quick fix, and risky to guess at further given it's security-sensitive (CSRF) code. + +## What's automated vs. manual + +To be precise about what future changes will and won't be automatically re-verified: + +- **Automated and passing:** all 618 `Tests/Unit` tests, and 78 of 87 `Tests/Functional` tests (6 errors + 3 failures remain, both documented above as pre-existing/out-of-scope, not introduced by this branch). +- **Not automated:** the Segment-filtering and Campaign-condition walkthroughs described in "Headline features" above were manual, one-off browser checks via Playwright — nothing was saved as a regression test. The `Tests/Functional` suite already has *some* automated coverage for this same ground (`CampaignConditionTest`, the `Segment/Query/Filter/*` tests), which is exactly how the `TokenSubscriber` and `ContactFilterMatcher` bugs above were actually found — but it isn't 1:1 with what was manually tested, and `CampaignConditionTest::testConditionForm` itself is one of the still-failing tests. + +## Recommendation + +`chore/mautic6-official-verify` has already been fast-forward-merged into local `6.x` and pushed to `webanyone/6.x` (all commits listed above). All fixes are small, targeted, and independently verified — either via review + re-test, or via direct instantiation through the real DI container for both `TokenSubscriber` fixes. The full unit suite (618/618), the great majority of the functional suite (78/87), and both headline end-user flows (Segment filtering, Campaign conditions) now pass against a fresh, official Mautic 6.0.9. A repo-wide grep confirms no single-colon `Class:method` controller-reference strings remain anywhere in `Controller/`. + +Two follow-up items worth tracking separately, both documented in detail above: + +1. The `beberlei/doctrineextensions` / `doctrine/lexer` incompatibility breaking MySQL full-text search — this is a Mautic-core-level dependency issue, likely affecting search across the whole application, not just this plugin. Worth raising with whoever owns the Mautic core fork/composer constraints. +2. The Symfony 6 session/CSRF continuity gap in `Tests/Functional` (1 error, 3 failures) — needs someone to sit down with Symfony 6's actual `KernelBrowser::loginUser()`/`session.factory` mechanics rather than a quick patch, given it's CSRF-related. + +No action needed regarding the mkcert/HTTPS gap or the migrations-metadata-storage note — both are environment-specific and out of scope for the plugin, but worth keeping in mind for future test runs on this host. diff --git a/docs/superpowers/specs/2026-07-29-mautic6-official-verify-design.md b/docs/superpowers/specs/2026-07-29-mautic6-official-verify-design.md new file mode 100644 index 000000000..f62c31731 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-mautic6-official-verify-design.md @@ -0,0 +1,59 @@ +# Verify CustomObjectsBundle Against a Fresh Official Mautic 6 Instance + +**Date:** 2026-07-29 +**Status:** Approved +**Approach:** Boot-test driven, iterative crash-first (consistent with `docs/adr/0001-mautic5-migration-strategy.md` Decision 1, present on the `6.x`/`fix/dynamic-content-subscriber-mautic6` plugin branches) + +--- + +## Context + +This plugin already has Mautic-6-targeted work on the `6.x` branch, developed and boot-tested against the `WebAnyOne/mautic` fork's `6.0` branch (see `docs/plans/2026-03-06-mautic6-upgrade-design.md`, also on the `6.x` branch history). That fork carries fork-specific customizations on top of upstream Mautic, which can mask or introduce compatibility differences that wouldn't show up against a real, unmodified Mautic 6 install. + +Separately, the current branch (`fix/dynamic-content-subscriber-mautic5`) has newer fixes not yet ported to any Mautic-6-targeted branch, including a plugin-schema-install fix (`fix(install): create plugin schema via the supported event API`) that touches exactly the kind of install-time code path a fresh instance would exercise. + +The goal of this pass: confirm — and where needed, fix — that the plugin boots and works against a **fresh, official, unmodified Mautic 6 instance** (`mautic/mautic` upstream, not the fork), starting from the `6.x` branch as the baseline. + +--- + +## Approach + +### Isolation via git worktrees + +Both the Mautic core checkout (`~/WS/webanyone/mautic`, currently on `5.2` with local uncommitted tweaks and a live dev DB) and the plugin checkout (this directory, currently on `fix/dynamic-content-subscriber-mautic5` with in-progress work) must stay untouched. Git worktrees give isolated working directories without disturbing either: + +- **Mautic core:** `git worktree add ../mautic-6-official 6.0.9`, detached at the latest stable 6.x tag, off the existing `upstream` remote (`mautic/mautic`) already fetched into `~/WS/webanyone/mautic`. No new clone needed. +- **Plugin:** `git worktree add ../mc-cs-plugin-custom-objects.mautic6-official -b chore/mautic6-official-verify 6.x`, a new branch off `6.x`, so any tweaks land as a reviewable diff against `6.x` rather than mixing into the current branch's history. + +### Wiring the plugin into the fresh instance + +Add a `web_mounts` entry to the fresh Mautic worktree's `.ddev/config.yaml`, bind-mounting the plugin worktree directly at `plugins/CustomObjectsBundle`. This is the "long-term resolution" the existing ADR (Decision 3) identified but deferred: a real bind mount, unlike a symlink, resolves correctly via `realpath()` inside the Docker container, so Doctrine entity mapping and bundle discovery work without a copy step. Composer is not involved in installing the plugin package — Mautic discovers bundles under `plugins/` directly — so no VCS repository or `composer.json` version constraint juggling is needed for this test. + +This is a local-only modification to the fresh worktree's DDEV config (uncommitted, throwaway); it doesn't touch the plugin's own `composer.json` or the fork. + +### Boot and iterate + +`ddev start` in the fresh Mautic worktree runs its own (upstream-provided) non-interactive post-start hook: `composer install` → `mautic:install` → `cache:warmup` → `mautic:plugins:reload`. + +Following the same method already validated in this repo's ADR-0001 (Decision 1: iterative crash-first debugging, not a full upfront audit): boot, observe the first fatal error (if any), fix it in the plugin worktree, re-run, repeat until: + +- `cache:clear`/`cache:warmup` completes with no fatal errors +- `mautic:plugins:reload` installs the plugin (including schema) without error +- The admin UI loads and is navigable (`admin` / `Maut1cR0cks!`) +- The Custom Objects list page renders +- Creating a Custom Object and a Custom Item works end-to-end + +A likely early failure: the `6.x` branch predates the current branch's `fix(install): create plugin schema via the supported event API` commit, so plugin schema installation may fail the same way that fix addressed. If so, port that fix (and only that fix, per crash-first scope discipline — not the other four pending commits, which are a separate concern from this verification pass). + +### Out of scope + +- Porting the full set of pending mautic5-branch commits (FilterEvaluator refactor, evaluator int-type fix, new unit tests) to the Mautic 6 branches. Only port what's needed to unblock a boot/install failure encountered here. +- Testing against the `WebAnyOne/mautic` fork or its `6.0` branch. +- Merging `chore/mautic6-official-verify` back into `6.x` — that's a follow-up decision once the pass is complete and findings are reviewed. +- Tearing down the DDEV environment / worktrees automatically at the end — leave them running for follow-up inspection unless told otherwise. + +--- + +## Success Criteria + +Same five checks listed above under "Boot and iterate." Report is a pass/fail per criterion, with root cause and fix (if any) for each failure encountered along the way. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 59bfd9961..f0a54ab83 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -692,6 +692,11 @@ parameters: count: 1 path: Tests/Unit/Segment/Query/UnionQueryContainerTest.php + - + message: "#^Constant MAUTIC_TABLE_PREFIX not found\\.$#" + count: 1 + path: Helper/ContactFilterMatcher.php + - message: """ #^Fetching deprecated class constant PARAM_INT_ARRAY of class Doctrine\\\\DBAL\\\\Connection\\: