cleanup(); $this->scheduledTasksRepository = $this->diContainer->get(ScheduledTasksRepository::class); $this->segmentsRepository = $this->diContainer->get(SegmentsRepository::class); $this->subscribersRepository = $this->diContainer->get(SubscribersRepository::class); $this->action = $this->diContainer->get(SendEmailAction::class); $this->subscriberSubject = $this->diContainer->get(SubscriberSubject::class); $this->segmentSubject = $this->diContainer->get(SegmentSubject::class); $this->automation = new Automation('test-automation', [], new \WP_User()); } public function _after() { parent::_after(); $this->cleanup(); } public function testItReturnsRequiredSubjects() { $this->assertSame(['mailpoet:subscriber'], $this->action->getSubjectKeys()); } public function testItIsNotValidIfStepHasNoEmail(): void { $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', [], []); $error = null; try { $this->action->validate(new StepValidationArgs($this->automation, $step, [])); } catch (ValidationException $error) { $this->assertSame('Automation email not found.', $error->getErrors()['email_id']); } $this->assertNotNull($error); } public function testItRequiresAutomationEmailType(): void { $newsletter = (new Newsletter())->withPostNotificationsType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $newsletter->getId()], []); $error = null; try { $this->action->validate(new StepValidationArgs($this->automation, $step, [])); $this->action->validate(new StepValidationArgs($this->automation, $step, [])); } catch (ValidationException $error) { $this->assertSame("Automation email with ID '{$newsletter->getId()}' not found.", $error->getErrors()['email_id']); } $this->assertNotNull($error); } public function testHappyPath() { $segment = (new Segment())->create(); $subscriber = (new Subscriber()) ->withStatus(SubscriberEntity::STATUS_SUBSCRIBED) ->withSegments([$segment]) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation('some-automation', [$step->getId() => $step], new \WP_User(), 1); $run = new AutomationRun(1, 1, 'trigger-key', $subjects, 1); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); // first run $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 1); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); $this->action->run($args, $controller); // scheduled task $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(1); $this->assertSame([ 'automation' => ['id' => 1, 'run_id' => 1, 'step_id' => 'step-id', 'run_number' => 1], ], $scheduled[0]->getMeta()); // scheduled task subscriber $this->scheduledTasksRepository->refresh($scheduled[0]); $scheduledTaskSubscribers = $scheduled[0]->getSubscribers(); $this->assertCount(1, $scheduledTaskSubscribers); $this->assertInstanceOf(ScheduledTaskSubscriberEntity::class, $scheduledTaskSubscribers[0]); // mark email as sent $scheduledTaskSubscribers[0]->setProcessed(ScheduledTaskSubscriberEntity::STATUS_PROCESSED); // progress — won't throw an exception when the email was sent (= step will be completed) $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 2); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); $this->action->run($args, $controller); } public function testItChecksThatEmailWasSent(): void { $segment = (new Segment())->create(); $subscriber = (new Subscriber()) ->withStatus(SubscriberEntity::STATUS_SUBSCRIBED) ->withSegments([$segment]) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation('some-automation', [$step->getId() => $step], new \WP_User(), 1); $run = new AutomationRun(1, 1, 'trigger-key', [], 1); // progress run step args $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 2); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); // email was never scheduled $this->assertThrowsExceptionWithMessage( 'Email failed to schedule.', function() use ($args, $controller) { $this->action->run($args, $controller); } ); // create scheduled task & subscriber $scheduledTask = (new ScheduledTask())->create('sending', ScheduledTaskEntity::STATUS_SCHEDULED); $scheduledTask->setMeta(['automation' => ['run_id' => $run->getId(), 'step_id' => $step->getId(), 'run_number' => 1]]); (new SendingQueue())->create($scheduledTask, $email); $scheduledTaskSubscriber = (new ScheduledTaskSubscriber())->createFailed($scheduledTask, $subscriber, 'Test error'); // email failed to send $this->assertThrowsExceptionWithMessage( 'Email failed to send. Error: Test error', function() use ($args, $controller) { $this->action->run($args, $controller); } ); // mark subscriber unprocessed $scheduledTaskSubscriber->setProcessed(ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED); $scheduledTaskSubscriber->setFailed(ScheduledTaskSubscriberEntity::FAIL_STATUS_OK); $scheduledTaskSubscriber->setError(null); // email was not sent yet, scheduling next progress (no exception) $actionScheduler = $this->diContainer->get(ActionScheduler::class); $this->assertCount(0, $actionScheduler->getScheduledActions()); $this->action->run($args, $controller); $actions = array_values($actionScheduler->getScheduledActions()); $this->assertCount(1, $actions); $this->assertSame('mailpoet/automation/step', $actions[0]->get_hook()); $this->assertSame([['automation_run_id' => $run->getId(), 'step_id' => 'step-id', 'run_number' => 3]], $actions[0]->get_args()); // email was never sent (7th run is the last check after ~1 month) $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 7); $this->assertThrowsExceptionWithMessage( 'Email sending process timed out.', function() use ($args, $controller) { $this->action->run($args, $controller); } ); } public function testEmailSentHandlerEnqueuesProgress(): void { $action = $this->getServiceWithOverrides(SendEmailAction::class, [ 'automationController' => $this->make(AutomationController::class, [ 'enqueueProgress' => Expected::once(), ]), ]); $action->handleEmailSent([ 'run_id' => 1, 'step_id' => 'abc', ]); } public function testEmailSentHandlerValidatesData() { $this->assertThrowsExceptionWithMessage( 'Invalid automation step data. Array expected, got: NULL', function() { $this->action->handleEmailSent(null); } ); $this->assertThrowsExceptionWithMessage( "Invalid automation step data. Expected 'run_id' to be an integer, got: string", function() { $this->action->handleEmailSent(['run_id' => 'abc']); } ); $this->assertThrowsExceptionWithMessage( "Invalid automation step data. Expected 'step_id' to be a string, got: integer", function() { $this->action->handleEmailSent(['run_id' => 123]); } ); } public function testNothingScheduledIfSegmentDeleted(): void { $segment = (new Segment())->create(); $subscriber = (new Subscriber()) ->withStatus(SubscriberEntity::STATUS_SUBSCRIBED) ->withSegments([$segment]) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation('some-automation', [$step->getId() => $step], new \WP_User()); $run = new AutomationRun(1, 1, 'trigger-key', $subjects); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); $this->segmentsRepository->bulkDelete([$segment->getId()]); $action = ContainerWrapper::getInstance()->get(SendEmailAction::class); $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 1); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); try { $action->run($args, $controller); } catch (Exception $exception) { // The exception itself isn't as important as the outcome } $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); } public function testNothingScheduledIfSubscriberDeleted(): void { $segment = (new Segment())->create(); $subscriber = (new Subscriber()) ->withStatus(SubscriberEntity::STATUS_SUBSCRIBED) ->withSegments([$segment]) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation('some-automation', [$step->getId() => $step], new \WP_User()); $run = new AutomationRun(1, 1, 'trigger-key', $subjects); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); $this->subscribersRepository->bulkDelete([$subscriber->getId()]); $action = ContainerWrapper::getInstance()->get(SendEmailAction::class); $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 1); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); try { $action->run($args, $controller); } catch (Exception $exception) { // The exception itself isn't as important as the outcome } $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); } public function testNothingScheduledIfSubscriberIsNotGloballySubscribed(): void { $segment = (new Segment())->create(); $otherStatuses = [ SubscriberEntity::STATUS_UNCONFIRMED, SubscriberEntity::STATUS_INACTIVE, SubscriberEntity::STATUS_BOUNCED, SubscriberEntity::STATUS_UNSUBSCRIBED, ]; foreach ($otherStatuses as $status) { $subscriber = (new Subscriber()) ->withStatus($status) ->withSegments([$segment]) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation('some-automation', [$step->getId() => $step], new \WP_User()); $run = new AutomationRun(1, 1, 'trigger-key', $subjects); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); $this->subscribersRepository->bulkDelete([$subscriber->getId()]); $action = ContainerWrapper::getInstance()->get(SendEmailAction::class); $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 1); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); try { $action->run($args, $controller); } catch (Exception $exception) { // The exception itself isn't as important as the outcome } $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); } } public function testNothingScheduledIfSubscriberNotSubscribedToSegment(): void { $segment = (new Segment())->create(); $subscriber = (new Subscriber()) ->withStatus(SubscriberEntity::STATUS_SUBSCRIBED) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationType()->create(); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation('some-automation', [$step->getId() => $step], new \WP_User()); $run = new AutomationRun(1, 1, 'trigger-key', $subjects); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); $action = ContainerWrapper::getInstance()->get(SendEmailAction::class); $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 1); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); try { $action->run($args, $controller); } catch (Exception $exception) { // The exception itself isn't as important as the outcome } $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); } public function testItSchedulesTransactionalEmailsWhenNotSubscribedToSegment(): void { $segment = (new Segment())->create(); $subscriber = (new Subscriber()) ->withStatus(SubscriberEntity::STATUS_UNSUBSCRIBED) ->create(); $subjects = $this->getSubjectData($subscriber, $segment); $email = (new Newsletter())->withAutomationTransactionalType()->create(); $root = new Step('root', Step::TYPE_ROOT, 'root', [], [new NextStep('trigger')]); $trigger = new Step('trigger-id', Step::TYPE_TRIGGER, 'woocommerce:order-status-changed', [], [new NextStep('step-id')]); $step = new Step('step-id', Step::TYPE_ACTION, 'step-key', ['email_id' => $email->getId()], []); $automation = new Automation( 'some-automation', [ 'root' => $root, $trigger->getId() => $trigger, $step->getId() => $step, ], new \WP_User(), 1 ); $run = new AutomationRun(1, 1, 'trigger-key', $subjects, 1); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(0); $action = ContainerWrapper::getInstance()->get(SendEmailAction::class); $args = new StepRunArgs($automation, $run, $step, $this->getSubjectEntries($subjects), 1); $controller = $this->diContainer->get(StepRunControllerFactory::class)->createController($args); $action->run($args, $controller); $scheduled = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($email, (int)$subscriber->getId()); verify($scheduled)->arrayCount(1); } /** * @dataProvider dataForTestItStoresAnTransactionalEmail * * @param Step[] $steps */ public function testItStoresAnTransactionalEmail(array $steps, string $expectedEmailType): void { $newsletter = new NewsletterEntity(); $newsletter->setType(NewsletterEntity::TYPE_AUTOMATION); $newsletter->setSubject('subject'); $newsletterRepository = $this->diContainer->get(NewslettersRepository::class); $newsletterRepository->persist($newsletter); $newsletterRepository->flush(); foreach ($steps as $key => $step) { if ($step->getKey() !== SendEmailAction::KEY) { continue; } $steps[$key] = new Step($step->getId(), $step->getType(), $step->getKey(), ['email_id' => $newsletter->getId()], []); } $newsletterRepository = $this->diContainer->get(NewslettersRepository::class); $newsletterRepository->persist($newsletter); $newsletterRepository->flush(); /** @var UpdateAutomationController $controller */ $controller = $this->diContainer->get(UpdateAutomationController::class); $automation = (new AutomationFactory())->withSteps($steps)->create(); $data = [ 'steps' => array_map( function(Step $step): array { return $step->toArray(); }, $automation->getSteps() ), ]; $controller->updateAutomation( $automation->getId(), $data ); $newsletter = $newsletterRepository->findOneById($newsletter->getId()); $this->assertInstanceOf(NewsletterEntity::class, $newsletter); $this->assertEquals($expectedEmailType, $newsletter->getType()); } public function dataForTestItStoresAnTransactionalEmail(): array { $root = new Step('root', Step::TYPE_ROOT, 'root', [], [new NextStep('trigger')]); $trigger = new Step('trigger', Step::TYPE_TRIGGER, 'woocommerce:order-status-changed', [], [new NextStep('emailstep')]); $emailStep = new Step('emailstep', Step::TYPE_ACTION, SendEmailAction::KEY, [], []); $isTransactional = [ 'steps' => [$root, $trigger, $emailStep], 'expected_type' => NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL, ]; $nonTransactionalTrigger = new Step('trigger', Step::TYPE_TRIGGER, 'some-other-trigger', [], [new NextStep('emailstep')]); $isNotTransactionalBecauseOfTrigger = [ 'steps' => [$root, $nonTransactionalTrigger, $emailStep], 'expected_type' => NewsletterEntity::TYPE_AUTOMATION, ]; $positionTrigger = new Step('trigger', Step::TYPE_TRIGGER, 'woocommerce:order-status-changed', [], [new NextStep('action')]); $someAction = new Step('action', Step::TYPE_ACTION, 'some-action', [], [new NextStep('emailstep')]); $isNotTransactionalBecauseOfPosition = [ 'steps' => [$root, $positionTrigger, $someAction, $emailStep], 'expected_type' => NewsletterEntity::TYPE_AUTOMATION, ]; return [ 'is_transactional' => $isTransactional, 'is_not_transactional_because_of_trigger' => $isNotTransactionalBecauseOfTrigger, 'is_not_transactional_because_of_position' => $isNotTransactionalBecauseOfPosition, ]; } private function getSubjectData(SubscriberEntity $subscriber, SegmentEntity $segment): array { return [ new Subject('mailpoet:segment', ['segment_id' => $segment->getId()]), new Subject('mailpoet:subscriber', ['subscriber_id' => $subscriber->getId()]), ]; } private function getSubjectEntries(array $subjects): array { $segmentData = array_filter($subjects, function (Subject $subject) { return $subject->getKey() === 'mailpoet:segment'; }); $subscriberData = array_filter($subjects, function (Subject $subject) { return $subject->getKey() === 'mailpoet:subscriber'; }); return [ new SubjectEntry($this->segmentSubject, reset($segmentData)), new SubjectEntry($this->subscriberSubject, reset($subscriberData)), ]; } private function assertThrowsExceptionWithMessage(string $expectedMessage, callable $callback): void { $error = null; try { $callback(); } catch (Throwable $e) { $error = $e->getMessage(); } $this->assertSame($expectedMessage, $error); } private function cleanup(): void { global $wpdb; $wpdb->query('TRUNCATE ' . $wpdb->prefix . 'actionscheduler_actions'); $wpdb->query('TRUNCATE ' . $wpdb->prefix . 'actionscheduler_claims'); } }