diff --git a/Actions/ConvertHtmlToTextActionTest.php b/Actions/ConvertHtmlToTextActionTest.php new file mode 100644 index 0000000..194c34f --- /dev/null +++ b/Actions/ConvertHtmlToTextActionTest.php @@ -0,0 +1,22 @@ +execute($html); + + $this->assertMatchesHtmlSnapshotWithoutWhitespace($text); + } +} diff --git a/Actions/PersonalizeHtmlActionTest.php b/Actions/PersonalizeHtmlActionTest.php new file mode 100644 index 0000000..ac6cc44 --- /dev/null +++ b/Actions/PersonalizeHtmlActionTest.php @@ -0,0 +1,67 @@ +send = factory(Send::class)->create(); + + $subscriber = $this->send->subscriber; + $subscriber->uuid = 'my-uuid'; + $subscriber->extra_attributes = ['first_name' => 'John', 'last_name' => 'Doe']; + $subscriber->save(); + + $this->send->campaign->update(['name' => 'my campaign']); + + $this->personalizeHtmlAction = new PersonalizeHtmlAction(); + } + + /** @test */ + public function it_can_replace_an_placeholder_for_a_subscriber_attribute() + { + $this->assertActionResult('::subscriber.uuid::', 'my-uuid'); + } + + /** @test */ + public function it_will_not_replace_a_non_existing_attribute() + { + $this->assertActionResult('::subscriber.non-existing::', '::subscriber.non-existing::'); + } + + /** @test */ + public function it_can_replace_an_placeholder_for_a_subscriber_extra_attribute() + { + $this->assertActionResult('::subscriber.extra_attributes.first_name::', 'John'); + } + + /** @test */ + public function it_will_not_replace_an_placeholder_for_a_non_existing_subscriber_extra_attribute() + { + $this->assertActionResult('::subscriber.extra_attributes.non-existing::', '::subscriber.extra_attributes.non-existing::'); + } + + protected function assertActionResult(string $inputHtml, $expectedOutputHtml) + { + $actualOutputHtml = (new PersonalizeHtmlAction())->execute($inputHtml, $this->send); + $this->assertEquals($expectedOutputHtml, $actualOutputHtml, "The personalize action did not produce the expected result. Expected: `{$expectedOutputHtml}`, actual: `{$actualOutputHtml}`"); + + $expectedOutputHtmlWithHtmlTags = "{$expectedOutputHtml}"; + $actualOutputHtmlWithHtmlTags = (new PersonalizeHtmlAction())->execute("{$inputHtml}", $this->send); + + $this->assertEquals($expectedOutputHtmlWithHtmlTags, $actualOutputHtmlWithHtmlTags, "The personalize action did not produce the expected result when wrapped in html tags. Expected: `{$expectedOutputHtmlWithHtmlTags}`, actual: `{$actualOutputHtmlWithHtmlTags}`"); + } +} diff --git a/Actions/PrepareEmailHtmlActionTest.php b/Actions/PrepareEmailHtmlActionTest.php new file mode 100644 index 0000000..e368e9b --- /dev/null +++ b/Actions/PrepareEmailHtmlActionTest.php @@ -0,0 +1,47 @@ +Hello

Hello world

'; + + $campaign = factory(Campaign::class)->create([ + 'track_clicks' => true, + 'html' => $myHtml, + ]); + + app(PrepareEmailHtmlAction::class)->execute($campaign); + + $campaign->refresh(); + + $this->assertMatchesHtmlSnapshotWithoutWhitespace($campaign->email_html); + } + + /** @test */ + public function it_will_add_html_tags_if_the_are_already_present() + { + $myHtml = '

Hello

Hello world

'; + + $campaign = factory(Campaign::class)->create([ + 'track_clicks' => true, + 'html' => $myHtml, + ]); + + app(PrepareEmailHtmlAction::class)->execute($campaign); + + $campaign->refresh(); + + $this->assertMatchesHtmlSnapshotWithoutWhitespace($campaign->email_html); + } +} diff --git a/Actions/SendWelcomeMailActionTest.php b/Actions/SendWelcomeMailActionTest.php new file mode 100644 index 0000000..faebbb9 --- /dev/null +++ b/Actions/SendWelcomeMailActionTest.php @@ -0,0 +1,35 @@ +subscriber = factory(Subscriber::class)->create(); + + $this->subscriber->emailList->update(['send_welcome_mail' => true]); + } + + /** @test */ + public function it_can_send_a_welcome_mail() + { + Mail::fake(); + + $action = new SendWelcomeMailAction(); + + $action->execute($this->subscriber); + + Mail::assertQueued(WelcomeMail::class); + } +} diff --git a/Actions/UpdateSubscriberActionTest.php b/Actions/UpdateSubscriberActionTest.php new file mode 100644 index 0000000..577e9ce --- /dev/null +++ b/Actions/UpdateSubscriberActionTest.php @@ -0,0 +1,58 @@ +subscriber = factory(Subscriber::class)->create(); + + $this->emailList = factory(EmailList::class)->create(); + + $this->anotherEmailList = factory(EmailList::class)->create(); + + $this->newAttributes = [ + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + ]; + } + + /** @test */ + public function it_can_update_the_attributes_of_a_subscriber() + { + $updateSubscriberAction = Config::getActionClass('update_subscriber', UpdateSubscriberAction::class); + + $updateSubscriberAction->execute( + $this->subscriber, + $this->newAttributes, + ); + + $this->subscriber->refresh(); + + $this->assertEquals('john@example.com', $this->subscriber->email); + $this->assertEquals('John', $this->subscriber->first_name); + $this->assertEquals('Doe', $this->subscriber->last_name); + } +} diff --git a/Actions/__snapshots__/ConvertHtmlToTextActionTest__it_can_convert_html_to_text__1.html b/Actions/__snapshots__/ConvertHtmlToTextActionTest__it_can_convert_html_to_text__1.html new file mode 100644 index 0000000..c1f4cb1 --- /dev/null +++ b/Actions/__snapshots__/ConvertHtmlToTextActionTest__it_can_convert_html_to_text__1.html @@ -0,0 +1,2 @@ + +

[Viewemailinbrowser](https://sendy.freek.dev/w/gMTJ0B9fBhUbUnImSK1gog)[![freek.dev](https://freek.dev/images/murzicoon.png)](https://freek.dev)FREEK.DEVHi,welcometothe94thfreek.devnewsletter![SendingawelcomenotificationtonewusersofaLaravelapp](https://freek.dev/1500-sending-a-welcome-notification-to-new-users-of-a-laravel-app)Toonboardnewuserscreatedbyotherusers,I'vecreatedanewpackagewhichcansendawelcomenotificationtonewusersthatallowsthemtosetaninitialpassword.[BuildyourownReact](https://pomb.us/build-your-own-react/)Inaverycoolpost,RodrigoPomboexplainstheinternalsofReactbyrewritingit'scorefromscratch.[ClosingModalswiththeBackButtoninaVueSPA](https://jessarcher.com/blog/closing-modals-with-the-back-button-in-a-vue-spa/)JessArcherrecentlygaveanexcellenttalkatLaraconAU.Inanewblogpostsheexplainsoneonetipsgivenduringhertalk:howtoclosemodalsinaVueappbyusingthebackbutton.[Self-hostyournewslettersandemailcampaigns](https://mailcoach.app)MyteamandIarecurrentlybuildingMailcoach,bothastandaloneappandLaravelpackagetosendoutnewsletters.[CreatingcustomrelationsinLaravel](https://stitcher.io/blog/laravel-custom-relation-classes)MycolleagueBrentsolvedaperformancebycreatingacustomrelation.[ImprovingArtisancommands](https://freek.dev/1492-improving-artisan-commands)Inthissmallblogpost,I'dliketogiveyouacoupleoftipstomakeyourArtisancommandsbetter.[WhatIsGarbageCollectioninPHPAndHowDoYouMakeTheMostOfIt?](https://tideways.com/profiler/blog/what-is-garbage-collection-in-php-and-how-do-you-make-the-most-of-it)OntheTidewaysblog,BenjaminEberleiexplainsPHPsgarbagecollectioninternals.[CraftingmaintainableLaravelapplications](https://jasonmccreary.me/articles/crafting-maintainable-laravel-applications/)AtLaraconAU,JasonMcCrearygaveanexcellentalkonhowtocreatemaintainableLaravelapps.Onhisbloghepublishedawrittendownversionofthetalk.MeanwhileonTwitter-[CherrypickthekeysforJSON.stringifytoserialize](https://twitter.com/TejasKumar_/status/1194326690924617728)-[Addquery-constraintswheneagerloadingrelationships](https://twitter.com/_stefanzweifel/status/1194323457477091329)-[Mutatingformrequestdata](https://twitter.com/neilkeena/status/1191410346075901953)Fromthearchives-[BuildingarealtimedashboardpoweredbyLaravel,Vue,PusherandTailwind(2018edition)](https://freek.dev/1212-building-a-realtime-dashboard-powered-by-laravel-vue-pusher-and-tailwind-2018-edition)-[AbetterwaytoregisterroutesinLaravel](https://freek.dev/1210-a-better-way-to-register-routes-in-laravel)-[HowPHPconferencescanbeimproved](https://freek.dev/1209-how-php-conferences-can-be-improved)-[LoadingEloquentrelationshipcounts](https://timacdonald.me/loading-eloquent-relationship-counts/)-[Callinganinvokableinaninstancevariable](https://freek.dev/1208-calling-an-invokable-in-an-instance-variable)-[Usingv-modelonNestedVueComponents](https://zaengle.com/blog/using-v-model-on-nested-vue-components)-[Areyousureyouneedentrustorlaravel-permissiontoimplementyourauthorization?](https://adelf.pro/2018/authorization-packages)-[Otherpeople'ssetup](https://freek.dev/1206-other-peoples-setup)-[FixingImagick's“notauthorized”exception](https://alexvanderbist.com/posts/2018/fixing-imagick-error-unauthorized)-[AutomaticmonitoringofLaravelForgemanagedsites](https://ohdear.app/blog/automatic-monitoring-of-laravel-forge-managed-sites)-[ngrok,lvh.meandnip.io:ATrilogyforLocalDevelopmentandTesting](https://nickjanetakis.com/blog/ngrok-lvhme-nipio-a-trilogy-for-local-development-and-testing)-[Whygeeksshouldspeak](https://justinjackson.ca/speak)-[MakingNovafieldstranslatable](https://freek.dev/1200-making-nova-fields-translatable)-[LaravelTelescope:Datatoolongforcolumn‘content’](https://ma.ttias.be/laravel-telescope-data-long-column-content/)Advertisementopportunitiesat[freek.dev/advertising]().Youarereceivingthismailbecauseyou'vesubscribedat[freek.dev](https://freek.dev).Optoutanytime.[Unsubscribe](https://sendy.freek.dev/unsubscribe-success.php?c=73).

diff --git a/Actions/__snapshots__/ConvertHtmlToTextActionTest__it_can_convert_html_to_text__1.php b/Actions/__snapshots__/ConvertHtmlToTextActionTest__it_can_convert_html_to_text__1.php new file mode 100644 index 0000000..48de945 --- /dev/null +++ b/Actions/__snapshots__/ConvertHtmlToTextActionTest__it_can_convert_html_to_text__1.php @@ -0,0 +1,62 @@ +). + +You are receiving this mail because you\'ve subscribed at [freek.dev](https://freek.dev). Opt out any time. [Unsubscribe](https://sendy.freek.dev/unsubscribe-success.php?c=73).'; diff --git a/Actions/__snapshots__/PrepareEmailHtmlActionTest__it_will_add_html_tags_if_the_are_already_present__1.html b/Actions/__snapshots__/PrepareEmailHtmlActionTest__it_will_add_html_tags_if_the_are_already_present__1.html new file mode 100644 index 0000000..30322ae --- /dev/null +++ b/Actions/__snapshots__/PrepareEmailHtmlActionTest__it_will_add_html_tags_if_the_are_already_present__1.html @@ -0,0 +1,6 @@ + + +

-//W3C//DTDHTML4.0Transitional//EN""http://www.w3.org/TR/REC-html40/loose.dtd">

+

Hello

+

Helloworld

+ diff --git a/Actions/__snapshots__/PrepareEmailHtmlActionTest__it_will_automatically_add_html_tags__1.html b/Actions/__snapshots__/PrepareEmailHtmlActionTest__it_will_automatically_add_html_tags__1.html new file mode 100644 index 0000000..30322ae --- /dev/null +++ b/Actions/__snapshots__/PrepareEmailHtmlActionTest__it_will_automatically_add_html_tags__1.html @@ -0,0 +1,6 @@ + + +

-//W3C//DTDHTML4.0Transitional//EN""http://www.w3.org/TR/REC-html40/loose.dtd">

+

Hello

+

Helloworld

+ diff --git a/Actions/stubs/newsletterHtml.txt b/Actions/stubs/newsletterHtml.txt new file mode 100644 index 0000000..7e33c9b --- /dev/null +++ b/Actions/stubs/newsletterHtml.txt @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Commands/CalculateStatisticsCommandTest.php b/Commands/CalculateStatisticsCommandTest.php new file mode 100644 index 0000000..6c7d625 --- /dev/null +++ b/Commands/CalculateStatisticsCommandTest.php @@ -0,0 +1,86 @@ +create([ + 'sent_at' => $sentAt, + 'statistics_calculated_at' => $statisticsCalculatedAt, + ]); + + $this->artisan(CalculateStatisticsCommand::class)->assertExitCode(0); + + $jobShouldHaveBeenDispatched + ? Bus::assertDispatched(CalculateStatisticsJob::class) + : Bus::assertNotDispatched(CalculateStatisticsJob::class); + } + + public function caseProvider(): array + { + TestTime::freeze('Y-m-d H:i:s', '2019-01-01 00:00:00'); + + return [ + [now()->subSecond(), null, true], + + [now()->subMinutes(2), now()->subMinute(), true], + + [now()->subMinutes(5), now()->subMinutes(1), true], + [now()->subMinutes(6), now()->subMinutes(1), false], + + [now()->subMinutes(20), now()->subMinutes(9), false], + [now()->subMinutes(20), now()->subMinutes(10)->subSecond(), true], + + [now()->subHours(3), now()->subHour(), false], + [now()->subHours(3), now()->subHour()->subSecond(), true], + + [now()->subDay()->subMinute(), now()->subHours(4), false], + [now()->subDay()->subMinute(), now()->subHours(4)->subSecond(), true], + + [now()->subWeeks(2), now()->subDay(), true], + [now()->subWeeks(2)->subSecond(), now()->subDay(), false], + + + ]; + } + + /** @test */ + public function it_can_recalculate_the_statistics_of_a_single_campaign() + { + $campaign = factory(Campaign::class)->create([ + 'sent_at' => now()->subYear(), + 'statistics_calculated_at' => null, + ]); + + $this->artisan(CalculateStatisticsCommand::class, ['campaignId' => 1])->assertExitCode(0); + + $this->assertNotNull($campaign->refresh()->statistics_calculated_at); + } +} diff --git a/Commands/DeleteOldUnconfirmedSubscribersCommandTest.php b/Commands/DeleteOldUnconfirmedSubscribersCommandTest.php new file mode 100644 index 0000000..7fa97b3 --- /dev/null +++ b/Commands/DeleteOldUnconfirmedSubscribersCommandTest.php @@ -0,0 +1,50 @@ +emailList = factory(EmailList::class)->create(['requires_confirmation' => true]); + } + + /** @test */ + public function it_will_delete_all_unconfirmed_subscribers_that_are_older_than_a_month() + { + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertEquals(SubscriptionStatus::UNCONFIRMED, $subscriber->status); + + TestTime::addMonth(); + $this->artisan(DeleteOldUnconfirmedSubscribersCommand::class)->assertExitCode(0); + $this->assertCount(1, Subscriber::all()); + + TestTime::addSecond(); + $this->artisan(DeleteOldUnconfirmedSubscribersCommand::class)->assertExitCode(0); + $this->assertCount(0, Subscriber::all()); + } + + /** @test */ + public function it_will_not_delete_confirmed_subscribers() + { + $subscriber = Subscriber::createWithEmail('john@example.com')->skipConfirmation()->subscribeTo($this->emailList); + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->status); + + TestTime::addMonth()->addSecond(); + $this->artisan(DeleteOldUnconfirmedSubscribersCommand::class)->assertExitCode(0); + $this->assertCount(1, Subscriber::all()); + } +} diff --git a/Commands/RetryPendingSendsCommandTest.php b/Commands/RetryPendingSendsCommandTest.php new file mode 100644 index 0000000..f054ae8 --- /dev/null +++ b/Commands/RetryPendingSendsCommandTest.php @@ -0,0 +1,31 @@ +create([ + 'sent_at' => null, + ]); + + $sentSend = factory(Send::class)->create([ + 'sent_at' => now(), + ]); + + $this->artisan(RetryPendingSendsCommand::class)->assertExitCode(0); + + Queue::assertPushed(SendMailJob::class, 1); + Queue::assertPushed(SendMailJob::class, fn (SendMailJob $job) => $job->pendingSend->id === $pendingSend->id); + } +} diff --git a/Commands/SendScheduledCampaignsCommandTest.php b/Commands/SendScheduledCampaignsCommandTest.php new file mode 100644 index 0000000..5b4de1c --- /dev/null +++ b/Commands/SendScheduledCampaignsCommandTest.php @@ -0,0 +1,77 @@ +create([ + 'scheduled_at' => null, + 'status' => CampaignStatus::DRAFT, + ]); + + $this->artisan(SendScheduledCampaignsCommand::class)->assertExitCode(0); + + Bus::assertNotDispatched(SendCampaignJob::class); + } + + /** @test */ + public function it_will_not_send_a_campaign_that_has_a_scheduled_at_in_the_future() + { + factory(Campaign::class)->create([ + 'scheduled_at' => now()->addSecond(), + 'status' => CampaignStatus::DRAFT, + ]); + + $this->artisan(SendScheduledCampaignsCommand::class)->assertExitCode(0); + + Bus::assertNotDispatched(SendCampaignJob::class); + } + + /** @test */ + public function it_will_send_a_campaign_that_has_a_scheduled_at_set_to_in_the_past() + { + factory(Campaign::class)->create([ + 'scheduled_at' => now()->subSecond(), + 'status' => CampaignStatus::DRAFT, + ]); + + $this->artisan(SendScheduledCampaignsCommand::class)->assertExitCode(0); + + Bus::assertDispatched(SendCampaignJob::class); + } + + /** @test */ + public function it_will_not_send_a_campaign_twice() + { + factory(Campaign::class)->create([ + 'scheduled_at' => now()->subSecond(), + 'status' => CampaignStatus::DRAFT, + ]); + + $this->artisan(SendScheduledCampaignsCommand::class)->assertExitCode(0); + Bus::assertDispatchedTimes(SendCampaignJob::class, 1); + + $this->artisan(SendScheduledCampaignsCommand::class)->assertExitCode(0); + Bus::assertDispatchedTimes(SendCampaignJob::class, 1); + } +} diff --git a/Events/CampaignLinkClickedEventTest.php b/Events/CampaignLinkClickedEventTest.php new file mode 100644 index 0000000..67afc54 --- /dev/null +++ b/Events/CampaignLinkClickedEventTest.php @@ -0,0 +1,60 @@ +create(); + $send->campaign->update(['track_clicks' => true]); + + $send->registerClick('https://spatie.be'); + + $this->assertCount(1, CampaignLink::get()); + + $this->assertDatabaseHas('mailcoach_campaign_links', [ + 'campaign_id' => $send->campaign->id, + 'url' => 'https://spatie.be', + ]); + + $this->assertDatabaseHas('mailcoach_campaign_clicks', [ + 'send_id' => $send->id, + 'campaign_link_id' => CampaignLink::first()->id, + 'subscriber_id' => $send->subscriber->id, + ]); + + Event::assertDispatched(CampaignLinkClickedEvent::class, function (CampaignLinkClickedEvent $event) use ($send) { + $this->assertEquals($send->uuid, $event->campaignClick->send->uuid); + + + return true; + }); + } + + /** @test */ + public function it_will_not_fire_an_event_when_a_link_gets_clicked_and_click_tracking_is_not_enable() + { + Event::fake(); + + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create(); + $send->campaign->update(['track_clicks' => false]); + + $send->registerClick('https://spatie.be'); + + $this->assertCount(0, CampaignLink::get()); + + Event::assertNotDispatched(CampaignLinkClickedEvent::class); + } +} diff --git a/Events/CampaignMailSentEventTest.php b/Events/CampaignMailSentEventTest.php new file mode 100644 index 0000000..e76ad48 --- /dev/null +++ b/Events/CampaignMailSentEventTest.php @@ -0,0 +1,28 @@ +create(); + + dispatch(new SendMailJob($send)); + + Event::assertDispatched(CampaignMailSentEvent::class, function (CampaignMailSentEvent $event) use ($send) { + $this->assertEquals($send->uuid, $event->send->uuid); + + return true; + }); + } +} diff --git a/Events/CampaignOpenedEventTest.php b/Events/CampaignOpenedEventTest.php new file mode 100644 index 0000000..46f1b0e --- /dev/null +++ b/Events/CampaignOpenedEventTest.php @@ -0,0 +1,41 @@ +create(); + + $send->campaign->update(['track_opens' => true]); + + $send->registerOpen(); + + Event::assertDispatched(CampaignOpenedEvent::class); + } + + /** @test */ + public function it_will_not_fire_an_event_when_a_campaign_is_opened_and_open_tracking_is_not_enabled() + { + Event::fake(CampaignOpenedEvent::class); + + /** @var Send $send */ + $send = factory(Send::class)->create(); + + $send->campaign->update(['track_opens' => false]); + + $send->registerOpen(); + + Event::assertNotDispatched(CampaignOpenedEvent::class); + } +} diff --git a/Events/CampaignSentEventTest.php b/Events/CampaignSentEventTest.php new file mode 100644 index 0000000..42070a9 --- /dev/null +++ b/Events/CampaignSentEventTest.php @@ -0,0 +1,28 @@ +withSubscriberCount(3)->create(); + + dispatch(new SendCampaignJob($campaign)); + + Event::assertDispatched(CampaignSentEvent::class, function (CampaignSentEvent $event) use ($campaign) { + $this->assertEquals($campaign->id, $event->campaign->id); + + return true; + }); + } +} diff --git a/Events/CampaignStatisticsCalculatedEventTest.php b/Events/CampaignStatisticsCalculatedEventTest.php new file mode 100644 index 0000000..d3d74eb --- /dev/null +++ b/Events/CampaignStatisticsCalculatedEventTest.php @@ -0,0 +1,28 @@ +create(); + + dispatch(new CalculateStatisticsJob($campaign)); + + Event::assertDispatched(CampaignStatisticsCalculatedEvent::class, function (CampaignStatisticsCalculatedEvent $event) use ($campaign) { + $this->assertEquals($campaign->id, $event->campaign->id); + + return true; + }); + } +} diff --git a/Events/SubscribedEventTest.php b/Events/SubscribedEventTest.php new file mode 100644 index 0000000..e5aaaa0 --- /dev/null +++ b/Events/SubscribedEventTest.php @@ -0,0 +1,69 @@ +create([ + 'requires_confirmation' => false, + ]); + + $emailList->subscribe('john@example.com'); + + Event::assertDispatched(SubscribedEvent::class, function (SubscribedEvent $event) { + $this->assertEquals('john@example.com', $event->subscriber->email); + + return true; + }); + } + + /** @test */ + public function it_will_not_fire_the_subscription_event_when_a_subscription_still_needs_to_be_confirmed() + { + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = factory(EmailList::class)->create([ + 'requires_confirmation' => true, + ]); + + $emailList->subscribe('john@example.com'); + + Event::assertNotDispatched(SubscribedEvent::class); + } + + /** @test */ + public function it_will_fire_the_subscribe_event_when_a_subscription_is_confirmed() + { + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = factory(EmailList::class)->create([ + 'requires_confirmation' => true, + ]); + + $subcriber = $emailList->subscribe('john@example.com'); + + Event::assertNotDispatched(SubscribedEvent::class); + + $subcriber->confirm(); + + Event::assertDispatched(SubscribedEvent::class, function (SubscribedEvent $event) { + $this->assertEquals('john@example.com', $event->subscriber->email); + + return true; + }); + } +} diff --git a/Events/UnconfirmedSubscriberCreatedEventTest.php b/Events/UnconfirmedSubscriberCreatedEventTest.php new file mode 100644 index 0000000..5fcb739 --- /dev/null +++ b/Events/UnconfirmedSubscriberCreatedEventTest.php @@ -0,0 +1,26 @@ +create([ + 'requires_confirmation' => true, + ]); + + $emailList->subscribe('john@example.com'); + + Event::assertDispatched(UnconfirmedSubscriberCreatedEvent::class); + } +} diff --git a/Events/UnsubscribedEventTest.php b/Events/UnsubscribedEventTest.php new file mode 100644 index 0000000..2ad957a --- /dev/null +++ b/Events/UnsubscribedEventTest.php @@ -0,0 +1,29 @@ +create(); + + $subscriber->unsubscribe(); + + Event::assertDispatched(UnsubscribedEvent::class, function (UnsubscribedEvent $event) use ($subscriber) { + $this->assertEquals($subscriber->id, $event->subscriber->id); + + return true; + }); + } +} diff --git a/Factories/CampaignFactory.php b/Factories/CampaignFactory.php new file mode 100644 index 0000000..5297d99 --- /dev/null +++ b/Factories/CampaignFactory.php @@ -0,0 +1,41 @@ +subscriberCount = $subscriberCount; + + return $this; + } + + public function create(array $attributes = []): Campaign + { + $emailList = (new EmailListFactory()) + ->withSubscriberCount($this->subscriberCount) + ->create([ + 'requires_confirmation' => false, + ]); + + $campaign = factory(Campaign::class) + ->create($attributes) + ->to($emailList); + + return $campaign->refresh(); + } + + public static function createSentAt(string $dateTime): Campaign + { + return factory(Campaign::class)->create([ + 'sent_at' => Carbon::createFromFormat('Y-m-d H:i:s', $dateTime), + ]); + } +} diff --git a/Factories/EmailListFactory.php b/Factories/EmailListFactory.php new file mode 100644 index 0000000..27fcf9d --- /dev/null +++ b/Factories/EmailListFactory.php @@ -0,0 +1,31 @@ +subscriberCount = $subscriberCount; + + return $this; + } + + public function create(array $attributes = []): EmailList + { + $emailList = factory(EmailList::class)->create($attributes); + + Collection::times($this->subscriberCount) + ->each(function (int $i) use ($emailList) { + $emailList->subscribe("subscriber{$i}@example.com"); + }); + + return $emailList->refresh(); + } +} diff --git a/Features/Controllers/App/Campaigns/CampaignHtmlControllerTest.php b/Features/Controllers/App/Campaigns/CampaignHtmlControllerTest.php new file mode 100644 index 0000000..78eca24 --- /dev/null +++ b/Features/Controllers/App/Campaigns/CampaignHtmlControllerTest.php @@ -0,0 +1,32 @@ +authenticate(); + + $campaign = factory(Campaign::class)->create(); + + $attributes = [ + 'html' => 'updated_html', + ]; + + $this + ->put( + action([CampaignContentController::class, 'update'], $campaign->id), + $attributes + ) + ->assertSessionHasNoErrors() + ->assertRedirect(action([CampaignContentController::class, 'edit'], $campaign->id)); + + $this->assertStringContainsString('updated_html', Campaign::first()->html); + } +} diff --git a/Features/Controllers/App/Campaigns/CampaignSettingsControllerTest.php b/Features/Controllers/App/Campaigns/CampaignSettingsControllerTest.php new file mode 100644 index 0000000..da3b8b8 --- /dev/null +++ b/Features/Controllers/App/Campaigns/CampaignSettingsControllerTest.php @@ -0,0 +1,41 @@ +withoutExceptionHandling(); + + $this->authenticate(); + + $campaign = Campaign::create(['name' => 'my campaign']); + + $attributes = [ + 'name' => 'updated name', + 'subject' => 'my subject', + 'email_list_id' => factory(EmailList::class)->create()->id, + 'track_opens' => true, + 'track_clicks' => true, + 'segment' => 'entire_list', + ]; + + $this + ->put( + action([CampaignSettingsController::class, 'update'], $campaign->id), + $attributes + ) + ->assertSessionHasNoErrors() + ->assertRedirect(action([CampaignSettingsController::class, 'edit'], $campaign->id)); + + $this->assertDatabaseHas('mailcoach_campaigns', Arr::except($attributes, ['segment'])); + } +} diff --git a/Features/Controllers/App/Campaigns/CreateCampaignControllerTest.php b/Features/Controllers/App/Campaigns/CreateCampaignControllerTest.php new file mode 100644 index 0000000..5b13421 --- /dev/null +++ b/Features/Controllers/App/Campaigns/CreateCampaignControllerTest.php @@ -0,0 +1,22 @@ +authenticate(); + + $this + ->post(action(CreateCampaignController::class), ['name' => 'my campaign']) + ->assertRedirect(action([CampaignSettingsController::class, 'edit'], 1)); + + $this->assertDatabaseHas('mailcoach_campaigns', ['name' => 'my campaign']); + } +} diff --git a/Features/Controllers/App/Campaigns/DestroyCampaignControllerTest.php b/Features/Controllers/App/Campaigns/DestroyCampaignControllerTest.php new file mode 100644 index 0000000..fe79ee8 --- /dev/null +++ b/Features/Controllers/App/Campaigns/DestroyCampaignControllerTest.php @@ -0,0 +1,24 @@ +authenticate(); + + $campaign = factory(Campaign::class)->create(); + + $this + ->delete(action(DestroyCampaignController::class, $campaign->id)) + ->assertRedirect(); + + $this->assertCount(0, Campaign::get()); + } +} diff --git a/Features/Controllers/App/Campaigns/DuplicateCampaignControllerTest.php b/Features/Controllers/App/Campaigns/DuplicateCampaignControllerTest.php new file mode 100644 index 0000000..04f7874 --- /dev/null +++ b/Features/Controllers/App/Campaigns/DuplicateCampaignControllerTest.php @@ -0,0 +1,45 @@ +authenticate(); + + /** @var \Spatie\Mailcoach\Models\Campaign $originalCampaign */ + $originalCampaign = factory(Campaign::class)->create(); + + $tag = Tag::create(['name' => 'test', 'email_list_id' => $originalCampaign->email_list_id]); + + $this + ->post(action(DuplicateCampaignController::class, $originalCampaign->id)) + ->assertRedirect(action([CampaignSettingsController::class, 'edit'], 2)); + + $duplicatedCampaign = Campaign::find(2); + + $this->assertEquals( + "Duplicate of {$originalCampaign->name}", + $duplicatedCampaign->name + ); + + foreach ([ + 'subject', + 'email_list_id', + 'html', + 'webview_html', + 'segment_class', + 'segment_id', + ] as $attribute) { + $this->assertEquals($duplicatedCampaign->$attribute, $originalCampaign->$attribute); + } + } +} diff --git a/Features/Controllers/App/Campaigns/ScheduleCampaignControllerTest.php b/Features/Controllers/App/Campaigns/ScheduleCampaignControllerTest.php new file mode 100644 index 0000000..7964820 --- /dev/null +++ b/Features/Controllers/App/Campaigns/ScheduleCampaignControllerTest.php @@ -0,0 +1,66 @@ +authenticate(); + + $this->campaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + TestTime::freeze('Y-m-d H:i:s', '2019-01-01 00:00:00'); + } + + /** @test */ + public function it_can_schedule_a_campaign() + { + $scheduleAt = now()->addDay(); + + $this + ->post( + action(ScheduleCampaignController::class, $this->campaign), + [ + 'scheduled_at' => [ + 'date' => $scheduleAt->format('Y-m-d'), + 'hours' => $scheduleAt->format('H'), + 'minutes' => $scheduleAt->format('i'), + ], + ] + ) + ->assertSessionHasNoErrors() + ->assertRedirect(action(CampaignDeliveryController::class, $this->campaign->id)); + + $this->assertEquals($scheduleAt->format('Y-m-d H:i:s'), $this->campaign->refresh()->scheduled_at->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_will_not_schedule_a_campaign_in_the_past() + { + $this->withExceptionHandling(); + + $scheduleAt = '2018-01-01 00:00:00'; + + $this + ->post( + action(ScheduleCampaignController::class, $this->campaign), + ['scheduled_at' => $scheduleAt] + ) + ->assertSessionHasErrors('scheduled_at'); + } +} diff --git a/Features/Controllers/App/Campaigns/SendCampaignControllerTest.php b/Features/Controllers/App/Campaigns/SendCampaignControllerTest.php new file mode 100644 index 0000000..14af08a --- /dev/null +++ b/Features/Controllers/App/Campaigns/SendCampaignControllerTest.php @@ -0,0 +1,39 @@ +authenticate(); + + $this->campaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + Bus::fake(); + } + + /** @test */ + public function it_can_send_a_campaign() + { + $this + ->post(action(SendCampaignController::class, $this->campaign->id)) + ->assertRedirect(action(CampaignSummaryController::class, $this->campaign->id)); + + Bus::assertDispatched(SendCampaignJob::class); + } +} diff --git a/Features/Controllers/App/Campaigns/SendTestEmailControllerTest.php b/Features/Controllers/App/Campaigns/SendTestEmailControllerTest.php new file mode 100644 index 0000000..931fd71 --- /dev/null +++ b/Features/Controllers/App/Campaigns/SendTestEmailControllerTest.php @@ -0,0 +1,40 @@ +authenticate(); + + $this->campaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + Bus::fake(); + } + + /** @test */ + public function it_can_send_test_mails() + { + $this->post( + action(SendTestEmailController::class, $this->campaign), + ['emails' => 'test@example.com'] + ); + + Bus::assertDispatched(SendTestMailJob::class); + } +} diff --git a/Features/Controllers/App/Campaigns/UnscheduleCampaignControllerTest.php b/Features/Controllers/App/Campaigns/UnscheduleCampaignControllerTest.php new file mode 100644 index 0000000..b83af74 --- /dev/null +++ b/Features/Controllers/App/Campaigns/UnscheduleCampaignControllerTest.php @@ -0,0 +1,27 @@ +authenticate(); + + $campaign = factory(Campaign::class)->create([ + 'scheduled_at' => now()->format('Y-m-d H:i:s') + ]); + + $this + ->post(action(UnscheduleCampaignController::class, $campaign->id)) + ->assertRedirect(action(CampaignDeliveryController::class, $campaign->id)); + + $this->assertNull($campaign->refresh()->scheduled_at); + } +} diff --git a/Features/Controllers/App/EmailLists/CreateEmailListControllerTest.php b/Features/Controllers/App/EmailLists/CreateEmailListControllerTest.php new file mode 100644 index 0000000..016e99e --- /dev/null +++ b/Features/Controllers/App/EmailLists/CreateEmailListControllerTest.php @@ -0,0 +1,30 @@ +authenticate(); + + $attributes = [ + 'name' => 'new list', + ]; + + $this + ->post( + action(CreateEmailListController::class), + $attributes + ) + ->assertSessionHasNoErrors() + ->assertRedirect(action(SubscribersIndexController::class, 1)); + + $this->assertDatabaseHas('mailcoach_email_lists', $attributes); + } +} diff --git a/Features/Controllers/App/EmailLists/DestroyEmailListControllerTest.php b/Features/Controllers/App/EmailLists/DestroyEmailListControllerTest.php new file mode 100644 index 0000000..47a35bf --- /dev/null +++ b/Features/Controllers/App/EmailLists/DestroyEmailListControllerTest.php @@ -0,0 +1,25 @@ +authenticate(); + + $emailList = factory(EmailList::class)->create(); + + $this + ->delete(action(DestroyEmailListController::class, $emailList->id)) + ->assertRedirect(action(EmailListsIndexController::class)); + + $this->assertCount(0, EmailList::get()); + } +} diff --git a/Features/Controllers/App/EmailLists/EmailSettingsControllerTest.php b/Features/Controllers/App/EmailLists/EmailSettingsControllerTest.php new file mode 100644 index 0000000..3b41eb2 --- /dev/null +++ b/Features/Controllers/App/EmailLists/EmailSettingsControllerTest.php @@ -0,0 +1,36 @@ +authenticate(); + + $emailList = EmailList::create([ + 'name' => 'my list', + ]); + + $attributes = [ + 'name' => 'updated name', + 'default_from_email' => 'jane@example.com', + 'default_from_name' => 'Jane Doe', + ]; + + $this + ->put( + action([EmailListSettingsController::class, 'update'], $emailList->id), + $attributes + ) + ->assertSessionHasNoErrors() + ->assertRedirect(action([EmailListSettingsController::class, 'edit'], $emailList->id)); + + $this->assertDatabaseHas('mailcoach_email_lists', $attributes); + } +} diff --git a/Features/Controllers/App/Subscribers/CreateSubscriberControllerTest.php b/Features/Controllers/App/Subscribers/CreateSubscriberControllerTest.php new file mode 100644 index 0000000..64ba6e5 --- /dev/null +++ b/Features/Controllers/App/Subscribers/CreateSubscriberControllerTest.php @@ -0,0 +1,39 @@ +authenticate(); + + /** @var EmailList $emailList */ + $emailList = factory(EmailList::class)->create(); + + $attributes = [ + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + ]; + + $this + ->post(action([CreateSubscriberController::class, 'store'], $emailList), $attributes) + ->assertSessionHasNoErrors(); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = Subscriber::first(); + + $this->assertEquals('john@example.com', $subscriber->email); + $this->assertEquals('John', $subscriber->first_name); + $this->assertEquals('Doe', $subscriber->last_name); + + $this->assertTrue($emailList->isSubscribed($subscriber->email)); + } +} diff --git a/Features/Controllers/App/Subscribers/DestroySubscriberControllerTest.php b/Features/Controllers/App/Subscribers/DestroySubscriberControllerTest.php new file mode 100644 index 0000000..89283c9 --- /dev/null +++ b/Features/Controllers/App/Subscribers/DestroySubscriberControllerTest.php @@ -0,0 +1,24 @@ +authenticate(); + + $subscriber = factory(Subscriber::class)->create(); + + $this + ->delete(action(DestroySubscriberController::class, [$subscriber->emailList->id, $subscriber->id])) + ->assertRedirect(); + + $this->assertCount(0, Subscriber::get()); + } +} diff --git a/Features/Controllers/App/Subscribers/ImportSubscribersControllerTest.php b/Features/Controllers/App/Subscribers/ImportSubscribersControllerTest.php new file mode 100644 index 0000000..f1a7678 --- /dev/null +++ b/Features/Controllers/App/Subscribers/ImportSubscribersControllerTest.php @@ -0,0 +1,176 @@ +emailList = factory(EmailList::class)->create(); + + $this->user = factory(User::class)->create(); + $this->actingAs($this->user); + + Mail::fake(); + } + + /** @test */ + public function it_can_subscribe_multiple_emails_in_one_go() + { + $this->uploadStub('valid-and-invalid.csv'); + + $this->assertCount(3, $this->emailList->subscribers); + + foreach (['freek@spatie.be', 'willem@spatie.be', 'rias@spatie.be'] as $email) { + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $this->emailList->getSubscriptionStatus($email)); + } + + $subscriberImport = SubscriberImport::first(); + + $this->assertEquals(3, $subscriberImport->imported_subscribers_count); + $this->assertEquals(1, $subscriberImport->error_count); + + Mail::assertSent(ImportSubscribersResultMail::class, function (ImportSubscribersResultMail $mail) use ($subscriberImport) { + $this->assertTrue($mail->hasTo($this->user->email)); + $this->assertEquals($subscriberImport->id, $mail->subscriberImport->id); + + return true; + }); + + Mail::assertNotQueued(WelcomeMail::class); + Mail::assertNotSent(WelcomeMail::class); + } + + /** @test */ + public function it_will_fill_the_correct_attributes() + { + $this->uploadStub('single.csv'); + + $this->assertCount(1, $this->emailList->subscribers); + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = Subscriber::first(); + $this->assertEquals('John', $subscriber->first_name); + $this->assertEquals('Doe', $subscriber->last_name); + $this->assertEquals('Developer', $subscriber->extra_attributes->job_title); + } + + /** @test */ + public function it_will_subscribe_the_emails_immediately_even_if_the_list_requires_confirmation() + { + $this->emailList->update(['requires_confirmation' => true]); + + $this->uploadStub('single.csv'); + + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + } + + + /** @test */ + public function it_will_not_import_a_subscriber_that_is_already_on_the_list() + { + Subscriber::createWithEmail('john@example.com') + ->skipConfirmation() + ->doNotSendWelcomeMail() + ->subscribeTo($this->emailList); + + $this->uploadStub('single.csv'); + $this->uploadStub('single.csv'); + $this->uploadStub('single.csv'); + + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + + $this->assertCount(1, Subscriber::all()); + } + + /** @test */ + public function it_can_import_tags() + { + $this->uploadStub('single.csv'); + + $subscriber = Subscriber::findForEmail('john@example.com', $this->emailList); + + $this->assertEquals(['tag1', 'tag2'], $subscriber->tags()->pluck('name')->toArray()); + } + + /** @test */ + public function it_will_not_subscribe_a_subscriber_that_has_unsubscribed_to_the_list_before() + { + $this->emailList->subscribeSkippingConfirmation('john@example.com'); + $this->emailList->unsubscribe('john@example.com'); + + $this->uploadStub('single.csv'); + + $this->assertFalse($this->emailList->isSubscribed('john@example.com')); + $this->assertCount(0, $this->emailList->subscribers); + $this->assertEquals(1, SubscriberImport::first()->error_count); + } + + /** @test */ + public function it_can_handle_an_empty_file() + { + $this->uploadStub('empty.csv'); + + $this->assertCount(0, $this->emailList->subscribers); + } + + /** @test */ + public function it_can_handle_an_invalid_file() + { + $this->uploadStub('invalid.csv'); + + $this->assertCount(0, $this->emailList->subscribers); + } + + private function uploadStub(string $stubName) + { + $stubPath = $this->getStubPath($stubName); + $tempPath = $this->getTempPath($stubName); + + File::copy($stubPath, $tempPath); + + $fileUpload = new UploadedFile( + $tempPath, + 'import.csv', + 'text/csv', + filesize($stubPath) + ); + + $this->call( + 'post', + action([ImportSubscribersController::class, 'import'], $this->emailList), + [], + [], + ['file' => $fileUpload] + ); + } + + private function getStubPath(string $name): string + { + return __DIR__ . '/stubs/' . $name; + } + + private function getTempPath(string $name): string + { + return __DIR__ . '/temp/' . $name; + } +} diff --git a/Features/Controllers/App/Subscribers/stubs/empty.csv b/Features/Controllers/App/Subscribers/stubs/empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/Features/Controllers/App/Subscribers/stubs/invalid.csv b/Features/Controllers/App/Subscribers/stubs/invalid.csv new file mode 100644 index 0000000..d31bff7 --- /dev/null +++ b/Features/Controllers/App/Subscribers/stubs/invalid.csv @@ -0,0 +1,4 @@ +em, +fsd,q +sdfq, +qsdk, diff --git a/Features/Controllers/App/Subscribers/stubs/single.csv b/Features/Controllers/App/Subscribers/stubs/single.csv new file mode 100644 index 0000000..d48a631 --- /dev/null +++ b/Features/Controllers/App/Subscribers/stubs/single.csv @@ -0,0 +1,2 @@ +email,first_name,last_name,extra_attributes.job_title,tags +john@example.com,John,Doe,Developer,tag1;tag2 diff --git a/Features/Controllers/App/Subscribers/stubs/valid-and-invalid.csv b/Features/Controllers/App/Subscribers/stubs/valid-and-invalid.csv new file mode 100644 index 0000000..a9264ac --- /dev/null +++ b/Features/Controllers/App/Subscribers/stubs/valid-and-invalid.csv @@ -0,0 +1,5 @@ +email,first_name,last_name,extra_attributes.job_title +freek@spatie.be,Freek,Van der Herten +willem@spatie.be, "Willem", "Van Bockstal" +rias@spatie.be, Rias,,developer +invalid-email diff --git a/Features/Controllers/App/Subscribers/temp/.gitignore b/Features/Controllers/App/Subscribers/temp/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/Features/Controllers/App/Subscribers/temp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/Features/Controllers/App/TemplatesControllerTest.php b/Features/Controllers/App/TemplatesControllerTest.php new file mode 100644 index 0000000..f8adf3f --- /dev/null +++ b/Features/Controllers/App/TemplatesControllerTest.php @@ -0,0 +1,63 @@ +authenticate(); + } + + /** @test */ + public function it_can_create_a_template() + { + $attributes = [ + 'name' => 'template name', + 'html' => 'template html', + ]; + + $this + ->post(action([TemplatesController::class, 'store']), $attributes) + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('mailcoach_templates', $attributes); + } + + /** @test */ + public function it_can_update_a_template() + { + $template = factory(Template::class)->create(); + + $attributes = [ + 'name' => 'template name', + 'html' => 'template html', + ]; + + $this + ->put(action([TemplatesController::class, 'update'], $template->id), $attributes) + ->assertSessionHasNoErrors(); + + $attributes['id'] = $template->id; + + $this->assertDatabaseHas('mailcoach_templates', $attributes); + } + + /** @test */ + public function it_can_delete_a_template() + { + $template = factory(Template::class)->create(); + + $this + ->delete(action([TemplatesController::class, 'destroy'], $template)) + ->assertRedirect(action([TemplatesController::class, 'index'])); + + $this->assertCount(0, Template::get()); + } +} diff --git a/Features/CustomizableActionTest.php b/Features/CustomizableActionTest.php new file mode 100644 index 0000000..5606f79 --- /dev/null +++ b/Features/CustomizableActionTest.php @@ -0,0 +1,103 @@ +set('mailcoach.actions.personalize_html', CustomPersonalizeHtmlAction::class); + + $campaign = (new CampaignFactory())->withSubscriberCount(1)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + dispatch(new SendCampaignJob($campaign)); + + $this->assertEquals('overridden@example.com', $campaign->emailList->subscribers->first()->email); + } + + /** @test */ + public function the_prepare_email_html_action_can_be_customized() + { + config()->set('mailcoach.actions.prepare_email_html', CustomPrepareEmailHtmlAction::class); + + $campaign = (new CampaignFactory())->withSubscriberCount(1)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + dispatch(new SendCampaignJob($campaign)); + + $this->assertEquals('overridden@example.com', $campaign->emailList->subscribers->first()->email); + } + + /** @test */ + public function the_prepare_webview_html_action_can_be_customized() + { + config()->set('mailcoach.actions.prepare_webview_html', CustomPrepareWebviewHtmlAction::class); + + $campaign = (new CampaignFactory())->withSubscriberCount(1)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + dispatch(new SendCampaignJob($campaign)); + + $this->assertEquals('overridden@example.com', $campaign->emailList->subscribers->first()->email); + } + + /** @test */ + public function the_create_subscriber_action_can_be_customized() + { + config()->set('mailcoach.actions.create_subscriber', CustomCreateSubscriberAction::class); + + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = factory(EmailList::class)->create(); + + $subscriber = $emailList->subscribe('john@example.com'); + + $this->assertEquals('overridden@example.com', $subscriber->email); + } + + /** @test */ + public function the_confirm_subscription_class_can_be_customized() + { + config()->set('mailcoach.actions.confirm_subscriber', CustomConfirmSubscriberAction::class); + + $emailList = factory(EmailList::class)->create([ + 'requires_confirmation' => true, + ]); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + + $subscriber->confirm(); + + $this->assertEquals('overridden@example.com', $subscriber->email); + } + + /** @test */ + public function a_wrongly_configured_class_will_result_in_an_exception() + { + config()->set('mailcoach.actions.create_subscriber', 'invalid-action'); + + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = factory(EmailList::class)->create(); + + $this->expectException(InvalidConfig::class); + + $emailList->subscribe('john@example.com'); + } +} diff --git a/Features/DoubleOptInTest.php b/Features/DoubleOptInTest.php new file mode 100644 index 0000000..22ac9e1 --- /dev/null +++ b/Features/DoubleOptInTest.php @@ -0,0 +1,75 @@ +emailList = factory(EmailList::class)->create(['requires_confirmation' => true]); + + Event::listen(MessageSent::class, function (MessageSent $event) { + $link = (new Crawler($event->message->getBody())) + ->filter('.button-primary')->first()->attr('href'); + + $this->mailedLink = Str::after($link, 'http://localhost'); + }); + + $this->emailList->subscribe('john@example.com'); + } + + /** @test */ + public function when_subscribing_to_a_double_opt_in_list_a_click_in_the_confirmation_mail_is_needed_to_subscribe() + { + $this->assertFalse($this->emailList->isSubscribed('john@example.com')); + + $this + ->get($this->mailedLink) + ->assertSuccessful(); + + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + } + + /** @test */ + public function clicking_the_mailed_link_twice_will_not_result_in_a_double_subscription() + { + $this + ->get($this->mailedLink) + ->assertSuccessful(); + + $this + ->get($this->mailedLink) + ->assertSuccessful() + ->assertViewIs('mailcoach::landingPages.alreadySubscribed'); + + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + $this->assertEquals(1, Subscriber::count()); + } + + /** @test */ + public function clicking_on_an_invalid_link_will_render_to_correct_response() + { + $content = $this + ->get(action(ConfirmSubscriberController::class, 'invalid-uuid')) + ->assertSuccessful() + ->assertViewIs('mailcoach::landingPages.couldNotFindSubscription'); + } +} diff --git a/Features/SegmentTest.php b/Features/SegmentTest.php new file mode 100644 index 0000000..0b0ff26 --- /dev/null +++ b/Features/SegmentTest.php @@ -0,0 +1,73 @@ +campaign = factory(Campaign::class)->create(); + + $this->emailList = factory(EmailList::class)->create(); + } + + /** @test */ + public function it_will_not_send_a_mail_if_it_is_not_subscribed_to_the_list_of_the_campaign_even_if_the_segment_selects_it() + { + factory(Subscriber::class)->create(); + + $this->campaign->segment(TestSegmentAllSubscribers::class)->sendTo($this->emailList); + + Mail::assertNothingSent(); + } + + /** @test */ + public function it_can_segment_a_test_by_using_a_query() + { + $this->emailList->subscribe('john@example.com'); + $this->emailList->subscribe('jane@example.com'); + + $this->campaign + ->segment(TestSegmentQueryOnlyJohn::class) + ->sendTo($this->emailList); + + Mail::assertSent(CampaignMail::class, 1); + + Mail::assertSent(CampaignMail::class, fn (CampaignMail $mail) => $mail->hasTo('john@example.com')); + + Mail::assertNotSent(CampaignMail::class, fn (CampaignMail $mail) => $mail->hasTo('jane@example.com')); + } + + /** @test */ + public function it_can_segment_a_test_by_using_should_send() + { + $this->emailList->subscribe('john@example.com'); + $this->emailList->subscribe('jane@example.com'); + $this->campaign + ->segment(TestCustomQueryOnlyShouldSendToJohn::class) + ->sendTo($this->emailList); + Mail::assertSent(CampaignMail::class, 1); + Mail::assertSent(CampaignMail::class, fn (CampaignMail $mail) => $mail->hasTo('john@example.com')); + Mail::assertNotSent(CampaignMail::class, fn (CampaignMail $mail) => $mail->hasTo('jane@example.com')); + } +} diff --git a/Features/UnsubscribeTest.php b/Features/UnsubscribeTest.php new file mode 100644 index 0000000..b09daea --- /dev/null +++ b/Features/UnsubscribeTest.php @@ -0,0 +1,153 @@ +campaign = (new CampaignFactory())->withSubscriberCount(1)->create([ + 'html' => 'Unsubscribe', + ]); + + $this->emailList = $this->campaign->emailList; + + $this->subscriber = $this->campaign->emailList->subscribers->first(); + } + + /** @test */ + public function it_can_unsubscribe_from_a_list() + { + $this->sendCampaign(); + + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $this->subscriber->status); + + $content = $this + ->get($this->mailedUnsubscribeLink) + ->assertSuccessful() + ->baseResponse->content(); + + $this->assertStringContainsString('unsubscribed', $content); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $this->subscriber->refresh()->status); + + $this->assertCount(1, CampaignUnsubscribe::all()); + $campaignUnsubscribe = CampaignUnsubscribe::first(); + + $this->assertEquals($this->subscriber->uuid, $campaignUnsubscribe->subscriber->uuid); + $this->assertEquals($this->campaign->uuid, $campaignUnsubscribe->campaign->uuid); + + $subscription = $this->emailList->allSubscribers()->first(); + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $subscription->status); + } + + /** @test */ + public function it_will_redirect_to_the_unsubscribed_view_by_default() + { + $this->sendCampaign(); + + $this + ->get($this->mailedUnsubscribeLink) + ->assertSuccessful() + ->assertViewIs('mailcoach::landingPages.unsubscribed'); + } + + /** @test */ + public function it_will_redirect_to_the_unsubscribed_url_if_it_has_been_set_on_the_email_list() + { + $url = 'https://example.com/unsubscribed'; + $this->campaign->emailList->update(['redirect_after_unsubscribed' => $url]); + + $this->sendCampaign(); + + $this + ->get($this->mailedUnsubscribeLink) + ->assertRedirect($url); + } + + /** @test */ + public function it_will_only_store_a_single_unsubscribe_even_if_the_unsubscribe_link_is_used_multiple_times() + { + $this->sendCampaign(); + + $this->get($this->mailedUnsubscribeLink)->assertSuccessful(); + $response = $this->get($this->mailedUnsubscribeLink)->assertSuccessful()->baseResponse->content(); + + $this->assertCount(1, CampaignUnsubscribe::all()); + + $this->assertStringContainsString('already unsubscribed', $response); + } + + /** @test */ + public function the_unsubscribe_will_work_even_if_the_send_is_deleted() + { + $this->sendCampaign(); + + Send::truncate(); + + $this->get($this->mailedUnsubscribeLink)->assertSuccessful(); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $this->subscriber->refresh()->status); + } + + /** @test */ + public function the_unsubscribe_header_is_added_to_the_email() + { + Event::listen(MessageSent::class, function (MessageSent $event) { + $subscription = $this->emailList->allSubscribers()->first(); + + $this->assertNotNull($event->message->getHeaders()->get('List-Unsubscribe')); + + $this->assertEquals('<'.url(action(UnsubscribeController::class, [$subscription->uuid, Send::first()->uuid])).'>', $event->message->getHeaders()->get('List-Unsubscribe')->getValue()); + + $this->assertNotNull($event->message->getHeaders()->get('List-Unsubscribe-Post')); + + $this->assertEquals('List-Unsubscribe=One-Click', $event->message->getHeaders()->get('List-Unsubscribe-Post')->getValue()); + }); + + dispatch(new SendCampaignJob($this->campaign)); + } + + protected function sendCampaign() + { + Event::listen(MessageSent::class, function (MessageSent $event) { + $link = (new Crawler($event->message->getBody())) + ->filter('a')->first()->attr('href'); + + $this->assertStringStartsWith('http://localhost', $link); + + $this->mailedUnsubscribeLink = Str::after($link, 'http://localhost'); + }); + + dispatch(new SendCampaignJob($this->campaign)); + } +} diff --git a/Features/WebviewTest.php b/Features/WebviewTest.php new file mode 100644 index 0000000..6a2ea98 --- /dev/null +++ b/Features/WebviewTest.php @@ -0,0 +1,56 @@ +campaign = (new CampaignFactory())->withSubscriberCount(1)->create([ + 'html' => 'My campaign Web view', + 'track_clicks' => true, + ]); + + $this->emailList = $this->campaign->emailList; + + $this->subscriber = $this->campaign->emailList->subscribers->first(); + } + + /** @test */ + public function it_can_sends_links_to_webviews() + { + $this->sendCampaign(); + + $this + ->get($this->webviewUrl) + ->assertSuccessful() + ->assertSee('My campaign'); + } + + protected function sendCampaign() + { + Event::listen(MessageSent::class, function (MessageSent $event) { + $link = (new Crawler($event->message->getBody())) + ->filter('a')->first()->attr('href'); + + $this->assertStringStartsWith('http://localhost', $link); + + $this->webviewUrl = Str::after($link, 'http://localhost'); + }); + + dispatch(new SendCampaignJob($this->campaign)); + } +} diff --git a/Http/Controllers/App/EmailLists/DestroyAllUnsubscribedControllerTest.php b/Http/Controllers/App/EmailLists/DestroyAllUnsubscribedControllerTest.php new file mode 100644 index 0000000..956e524 --- /dev/null +++ b/Http/Controllers/App/EmailLists/DestroyAllUnsubscribedControllerTest.php @@ -0,0 +1,40 @@ +authenticate(); + + $emailList = factory(EmailList::class)->create(['requires_confirmation' => false]); + $anotherEmailList = factory(EmailList::class)->create(['requires_confirmation' => false]); + + $subscriber = Subscriber::createWithEmail('subscribed@example.com')->subscribeTo($emailList); + + $unsubscribedSubscriber = Subscriber::createWithEmail('unsubscribed@example.com') + ->subscribeTo($emailList) + ->unsubscribe(); + + $unsubscribedSubscriberOfAnotherList = Subscriber::createWithEmail('unsubscribed-other-list@example.com') + ->subscribeTo($anotherEmailList) + ->unsubscribe(); + + $this + ->delete(route('mailcoach.emailLists.destroy-unsubscribes', $emailList->refresh())) + ->assertSessionHasNoErrors() + ->assertRedirect(); + + $existingSubscriberIds = Subscriber::pluck('id')->toArray(); + + $this->assertTrue(in_array($subscriber->id, $existingSubscriberIds)); + $this->assertFalse(in_array($unsubscribedSubscriber->id, $existingSubscriberIds)); + $this->assertTrue(in_array($unsubscribedSubscriberOfAnotherList->id, $existingSubscriberIds)); + } +} diff --git a/Http/Controllers/App/EmailLists/Subscribers/ResendConfirmationMailControllerTest.php b/Http/Controllers/App/EmailLists/Subscribers/ResendConfirmationMailControllerTest.php new file mode 100644 index 0000000..6b30543 --- /dev/null +++ b/Http/Controllers/App/EmailLists/Subscribers/ResendConfirmationMailControllerTest.php @@ -0,0 +1,26 @@ +authenticate(); + Mail::fake(); + + $emailList = factory(EmailList::class)->create(['requires_confirmation' => true]); + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + Mail::assertQueued(ConfirmSubscriberMail::class, 1); + + $this->post(route('mailcoach.subscriber.resend-confirmation-mail', $subscriber)); + Mail::assertQueued(ConfirmSubscriberMail::class, 2); + } +} diff --git a/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/ConfirmControllerTest.php b/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/ConfirmControllerTest.php new file mode 100644 index 0000000..3da4953 --- /dev/null +++ b/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/ConfirmControllerTest.php @@ -0,0 +1,53 @@ +authenticate(); + } + + /** @test */ + public function it_can_confirm_a_subscriber() + { + $emailList = factory(EmailList::class)->create(['requires_confirmation' => true]); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + + $this->assertEquals(SubscriptionStatus::UNCONFIRMED, $subscriber->status); + + $this + ->post(route('mailcoach.subscriber.confirm', $subscriber)) + ->assertRedirect(); + + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->refresh()->status); + } + + /** @test */ + public function it_will_confirm_unconfirmed_subscribers() + { + $this->withExceptionHandling(); + + $subscriber = factory(Subscriber::class)->create([ + 'unsubscribed_at' => now(), + 'subscribed_at' => now(), + ]); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $subscriber->status); + + $this + ->post(route('mailcoach.subscriber.confirm', $subscriber)) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + } +} diff --git a/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/ResubscribeControllerTest.php b/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/ResubscribeControllerTest.php new file mode 100644 index 0000000..b86319c --- /dev/null +++ b/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/ResubscribeControllerTest.php @@ -0,0 +1,51 @@ +authenticate(); + } + + /** @test */ + public function it_can_confirm_a_subscriber() + { + $emailList = factory(EmailList::class)->create(); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + $subscriber->unsubscribe(); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $subscriber->status); + + $this + ->post(route('mailcoach.subscriber.resubscribe', $subscriber)) + ->assertRedirect(); + + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->refresh()->status); + } + + /** @test */ + public function it_will_only_resubscribe_unsubscribed_subscribers() + { + $this->withExceptionHandling(); + $emailList = factory(EmailList::class)->create(); + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->status); + + $this + ->post(route('mailcoach.subscriber.resubscribe', $subscriber)) + ->assertSessionHas('laravel_flash_message.class', 'error'); + } +} diff --git a/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/UnsubscribeControllerTest.php b/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/UnsubscribeControllerTest.php new file mode 100644 index 0000000..5260426 --- /dev/null +++ b/Http/Controllers/App/EmailLists/Subscribers/UpdateStatus/UnsubscribeControllerTest.php @@ -0,0 +1,52 @@ +authenticate(); + } + + /** @test */ + public function it_can_unsubscribe_a_subscriber() + { + $emailList = factory(EmailList::class)->create(); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->status); + + $this + ->post(route('mailcoach.subscriber.unsubscribe', $subscriber)) + ->assertRedirect(); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $subscriber->refresh()->status); + } + + /** @test */ + public function it_will_only_unsubscribe_subscribed_subscribers() + { + $this->withExceptionHandling(); + + $emailList = factory(EmailList::class)->create(); + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($emailList); + $subscriber->unsubscribe(); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $subscriber->status); + + $this + ->post(route('mailcoach.subscriber.unsubscribe', $subscriber)) + ->assertSessionHas('laravel_flash_message.class', 'error'); + } +} diff --git a/Http/Controllers/CampaignWebviewControllerTest.php b/Http/Controllers/CampaignWebviewControllerTest.php new file mode 100644 index 0000000..407c679 --- /dev/null +++ b/Http/Controllers/CampaignWebviewControllerTest.php @@ -0,0 +1,49 @@ +campaign = factory(Campaign::class)->create([ + 'webview_html' => 'my webview html', + ]); + + $this->campaign->markAsSent(1); + + $this->webviewUrl = action(CampaignWebviewController::class, $this->campaign->uuid); + } + + /** @test */ + public function it_can_display_the_webview_for_a_campaign() + { + $this + ->get($this->webviewUrl) + ->assertSuccessful() + ->assertSee('my webview html'); + } + + /** @test */ + public function it_will_not_display_a_webview_for_a_campaign_that_has_not_been_sent() + { + $this->withExceptionHandling(); + + $this->campaign->update(['status' => CampaignStatus::DRAFT]); + + $this + ->get($this->webviewUrl) + ->assertStatus(404); + } +} diff --git a/Http/Controllers/EmailListCampaignsFeedControllerTest.php b/Http/Controllers/EmailListCampaignsFeedControllerTest.php new file mode 100644 index 0000000..b44bb0b --- /dev/null +++ b/Http/Controllers/EmailListCampaignsFeedControllerTest.php @@ -0,0 +1,49 @@ +withExceptionHandling(); + + $this->emailList = factory(EmailList::class)->create([ + 'campaigns_feed_enabled' => true, + ]); + + factory(Campaign::class)->create([ + 'email_list_id' => $this->emailList->id, + 'sent_at' => now(), + 'status' => CampaignStatus::SENT, + ]); + } + + /** @test */ + public function it_can_generate_a_feed() + { + $this + ->get(action(EmailListCampaignsFeedController::class, $this->emailList->uuid)) + ->assertSee('emailList->update(['campaigns_feed_enabled' => false]); + + $this + ->get(action(EmailListCampaignsFeedController::class, $this->emailList->uuid)) + ->assertStatus(404); + } +} diff --git a/Http/Controllers/SubscribeControllerTest.php b/Http/Controllers/SubscribeControllerTest.php new file mode 100644 index 0000000..694f498 --- /dev/null +++ b/Http/Controllers/SubscribeControllerTest.php @@ -0,0 +1,246 @@ +withExceptionHandling(); + + $this->emailList = factory(EmailList::class)->create([ + 'requires_confirmation' => false, + 'allow_form_subscriptions' => true, + 'redirect_after_subscribed' => 'https://example.com/redirect-after-subscribed', + 'redirect_after_already_subscribed' => 'https://example.com/redirect-after-already-subscribed', + 'redirect_after_subscription_pending' => 'https://example.com/redirect-after-subscription-pending', + 'redirect_after_unsubscribed' => 'https://example.com/redirect-after-unsubscribed', + + ]); + } + + /** @test */ + public function it_can_subscribe_to_an_email_list_without_double_opt_in() + { + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payloadWithRedirects()) + ->assertRedirect($this->payloadWithRedirects()['redirect_after_subscribed']); + + $this->assertEquals( + SubscriptionStatus::SUBSCRIBED, + $this->emailList->getSubscriptionStatus('john@example.com') + ); + } + + /** @test */ + public function when_not_specified_on_the_form_it_will_redirect_to_the_redirect_after_subscribed_url_on_the_list() + { + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payload()) + ->assertRedirect($this->emailList->redirect_after_subscribed); + } + + /** @test */ + public function when_no_redirect_after_subscribed_is_specified_on_the_request_or_email_list_it_will_redirect_show_a_view() + { + $this->withoutExceptionHandling(); + + $this->emailList->update(['redirect_after_subscribed' => null]); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payload()) + ->assertSuccessful() + ->assertViewIs('mailcoach::landingPages.subscribed'); + } + + /** @test */ + public function it_will_return_a_not_found_response_for_email_list_that_do_not_allow_form_subscriptions() + { + $this->emailList->update(['allow_form_subscriptions' => false]); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payloadWithRedirects()) + ->assertStatus(404); + } + + /** @test */ + public function it_can_accept_a_first_and_last_name() + { + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payloadWithRedirects([ + 'first_name' => 'John', + 'last_name' => 'Doe', + ])) + ->assertRedirect($this->payloadWithRedirects()['redirect_after_subscribed']); + + $subscriber = Subscriber::where('email', $this->payloadWithRedirects()['email'])->first(); + + $this->assertEquals('John', $subscriber->first_name); + $this->assertEquals('Doe', $subscriber->last_name); + } + + /** @test */ + public function it_can_accept_tags() + { + $test1Tag = Tag::create(['name' => 'test1', 'email_list_id' => $this->emailList->id]); + $test2Tag = Tag::create(['name' => 'test2', 'email_list_id' => $this->emailList->id]); + $test3Tag = Tag::create(['name' => 'test3', 'email_list_id' => $this->emailList->id]); + + $this->emailList->allowedFormSubscriptionTags()->sync([$test1Tag->id, $test3Tag->id]); + + $this + ->post( + action(SubscribeController::class, $this->emailList->uuid), + $this->payloadWithRedirects([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'tags' => 'test1;test2;test3' + ]) + ); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = Subscriber::where('email', $this->payloadWithRedirects()['email'])->first(); + + $this->assertEquals(['test1', 'test3'], $subscriber->tags()->pluck('name')->toArray()); + } + + /** @test */ + public function it_will_redirect_to_the_correct_url_if_the_email_address_is_already_subscribed() + { + $this->emailList->subscribe($this->payloadWithRedirects()['email']); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payloadWithRedirects()) + ->assertRedirect($this->payloadWithRedirects()['redirect_after_already_subscribed']); + } + + /** @test */ + public function when_not_specified_on_the_form_it_will_redirect_to_the_redirect_after_already_subscribed_url_on_the_list() + { + $this->withoutExceptionHandling(); + + $this->emailList->subscribe($this->payload()['email']); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payload()) + ->assertRedirect($this->emailList->redirect_after_already_subscribed); + } + + /** @test */ + public function when_no_redirect_after_already_subscribed_is_specified_on_the_request_or_email_list_it_will_redirect_show_a_view() + { + $this->emailList->subscribe($this->payload()['email']); + + $this->emailList->update(['redirect_after_already_subscribed' => null]); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payload()) + ->assertSuccessful() + ->assertViewIs('mailcoach::landingPages.alreadySubscribed'); + } + + /** @test */ + public function it_will_redirect_to_the_correct_url_if_the_subscription_is_pending() + { + $this->emailList->update(['requires_confirmation' => true]); + + $this->emailList->subscribe($this->payloadWithRedirects()['email']); + + $redirectUrl = 'https://mydomain/subscription-pending'; + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payloadWithRedirects( + ['redirect_after_subscription_pending' => $redirectUrl] + )) + ->assertRedirect($redirectUrl); + } + + /** @test */ + public function when_not_specified_on_the_form_it_will_redirect_to_the_redirect_after_subscription_pending_url_on_the_list() + { + $this->emailList->update(['requires_confirmation' => true]); + $this->emailList->subscribe($this->payload()['email']); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payload()) + ->assertRedirect($this->emailList->redirect_after_subscription_pending); + } + + /** @test */ + public function when_no_redirect_after_subscription_pending_is_specified_on_the_request_or_email_list_it_will_redirect_show_a_view() + { + $this->withoutExceptionHandling(); + + $this->emailList->update(['requires_confirmation' => true]); + $this->emailList->subscribe($this->payload()['email']); + + $this->emailList->update(['redirect_after_subscription_pending' => null]); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payload()) + ->assertSuccessful() + ->assertViewIs('mailcoach::landingPages.confirmSubscription'); + } + + /** @test */ + public function clicking_the_link_in_the_confirm_subscription_mail_will_redirect_to_the_given_url() + { + $this->emailList->update(['requires_confirmation' => true]); + + /* + * We'll grab the url behind the confirm subscription button in the mail that will be sent + */ + Event::listen(MessageSent::class, function (MessageSent $event) { + $this->confirmSubscriptionLink = (new Crawler($event->message->getBody())) + ->filter('.button-primary')->first()->attr('href'); + }); + + $this + ->post(action(SubscribeController::class, $this->emailList->uuid), $this->payloadWithRedirects()) + ->assertRedirect($this->payloadWithRedirects()['redirect_after_subscription_pending']); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = Subscriber::where('email', $this->payloadWithRedirects()['email'])->first(); + $this->assertEquals(SubscriptionStatus::UNCONFIRMED, $subscriber->refresh()->status); + + /* + * We'll pretend the user clicked the confirm subscription button by visiting the url + */ + $this + ->get($this->confirmSubscriptionLink) + ->assertRedirect($this->payloadWithRedirects()['redirect_after_subscribed']); + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->refresh()->status); + } + + public function payload(array $extraAttributes = []) + { + return array_merge([ + 'email' => 'john@example.com' + ], $extraAttributes); + } + + protected function payloadWithRedirects(array $extraAttributes = []): array + { + return array_merge([ + 'redirect_after_subscribed' => 'https://mydomain/subscribed', + 'redirect_after_already_subscribed' => 'https://mydomain/already-subscribed', + 'redirect_after_subscription_pending' => 'https://mydomain/subscription-pending', + ], $this->payload($extraAttributes)); + } +} diff --git a/Http/Middleware/AuthorizeTest.php b/Http/Middleware/AuthorizeTest.php new file mode 100644 index 0000000..343a592 --- /dev/null +++ b/Http/Middleware/AuthorizeTest.php @@ -0,0 +1,52 @@ +withExceptionHandling(); + + Route::get('login', function () { + return 'you should log in'; + })->name('login'); + + Route::middleware(Authorize::class)->group(function () { + Route::get('test', fn () => 'ok'); + }); + } + + /** @test */ + public function it_can_authorize() + { + $this->get('test')->assertRedirect('/login'); + + Gate::define('viewMailcoach', fn ($user) => $user->email === 'john@example.com'); + + $this->actingAs($this->getUser('john@example.com')); + $this->get('test')->assertStatus(200); + + $this->actingAs($this->getUser('another-user@example.com')); + $this->get('test')->assertRedirect('/login'); + + config()->set('mailcoach.redirect_unauthorized_users_to_route', ''); + $this->get('test')->assertStatus(403); + } + + protected function getUser(string $email): User + { + $user = new User(); + $user->email = $email; + + return $user; + } +} diff --git a/Http/Middleware/EditableCampaignTest.php b/Http/Middleware/EditableCampaignTest.php new file mode 100644 index 0000000..d600c2e --- /dev/null +++ b/Http/Middleware/EditableCampaignTest.php @@ -0,0 +1,31 @@ +authenticate(); + + /** @var \Spatie\Mailcoach\Models\Campaign $campaign */ + $campaign = factory(Campaign::class)->create(); + + $this + ->get(route('mailcoach.campaigns.settings', $campaign)) + ->assertSuccessful(); + + $campaign->send(); + + $this + ->get(route('mailcoach.campaigns.settings', $campaign)) + ->assertRedirect(route('mailcoach.campaigns.summary', $campaign)); + } +} diff --git a/Jobs/CalculateStatisticsJobTest.php b/Jobs/CalculateStatisticsJobTest.php new file mode 100644 index 0000000..cd138fd --- /dev/null +++ b/Jobs/CalculateStatisticsJobTest.php @@ -0,0 +1,211 @@ +create(); + + dispatch(new CalculateStatisticsJob($campaign)); + + $this->assertDatabaseHas('mailcoach_campaigns', [ + 'id' => $campaign->id, + 'sent_to_number_of_subscribers' => 0, + 'open_count' => 0, + 'unique_open_count' => 0, + 'open_rate' => 0, + 'click_count' => 0, + 'unique_click_count' => 0, + 'click_rate' => 0, + ]); + } + + /** @test */ + public function it_will_save_the_datetime_when_the_statistics_where_calculated() + { + TestTime::freeze(); + + $campaign = factory(Campaign::class)->create(); + $this->assertNull($campaign->statistics_calculated_at); + + dispatch(new CalculateStatisticsJob($campaign)); + $this->assertEquals(now()->format('Y-m-d H:i:s'), $campaign->fresh()->statistics_calculated_at); + } + + /** @test */ + public function it_can_calculate_statistics_regarding_unsubscribes() + { + $campaign = (new CampaignFactory())->withSubscriberCount(5)->create(); + dispatch(new SendCampaignJob($campaign)); + + dispatch(new CalculateStatisticsJob($campaign)); + + $this->assertDatabaseHas('mailcoach_campaigns', [ + 'id' => $campaign->id, + 'unsubscribe_count' => 0, + 'unsubscribe_rate' => 0, + ]); + + $sends = $campaign->sends()->take(3)->get(); + $this->simulateUnsubscribes($sends); + dispatch(new CalculateStatisticsJob($campaign)); + + $this->assertDatabaseHas('mailcoach_campaigns', [ + 'id' => $campaign->id, + 'unsubscribe_count' => 3, + 'unsubscribe_rate' => 60, + ]); + } + + /** @test */ + public function it_can_calculate_statistics_regarding_opens() + { + $campaign = (new CampaignFactory())->withSubscriberCount(5)->create(['track_opens' => true]); + dispatch(new SendCampaignJob($campaign)); + + $sends = $campaign->sends()->take(3)->get(); + $this + ->simulateOpen($sends) + ->simulateOpen($sends->take(1)); + + dispatch(new CalculateStatisticsJob($campaign)); + + $this->assertDatabaseHas('mailcoach_campaigns', [ + 'id' => $campaign->id, + 'open_count' => 4, + 'unique_open_count' => 3, + 'open_rate' => 60, + ]); + } + + /** @test */ + public function it_can_calculate_statistics_regarding_clicks_on_the_campaign() + { + $campaign = (new CampaignFactory())->withSubscriberCount(5)->create([ + 'html' => 'SpatieFlareDocs', + 'track_clicks' => true, + ]); + dispatch(new SendCampaignJob($campaign)); + + $subscribers = $campaign->emailList->subscribers->take(3); + collect(['https://spatie.be', 'https://example.com']) + ->each(function (string $url) use ($campaign, $subscribers) { + $this->simulateClick($campaign, $url, $subscribers); + }); + $this->simulateClick( + $campaign, + 'https://spatie.be', + $subscribers->take(1) + ); + + dispatch_now(new CalculateStatisticsJob($campaign)); + + $this->assertDatabaseHas('mailcoach_campaigns', [ + 'id' => $campaign->id, + 'sent_to_number_of_subscribers' => 5, + 'click_count' => 7, + 'unique_click_count' => 3, + 'click_rate' => 60, + ]); + } + + /** @test */ + public function it_can_calculate_statistics_regarding_clicks_on_individual_links() + { + $campaign = (new CampaignFactory())->withSubscriberCount(3)->create([ + 'html' => 'Spatie', + 'track_clicks' => true, + ]); + dispatch(new SendCampaignJob($campaign)); + + $subscriber1 = $campaign->emailList->subscribers[0]; + $subscriber2 = $campaign->emailList->subscribers[1]; + $subscriber3 = $campaign->emailList->subscribers[2]; + + $url = 'https://spatie.be'; + + $this + ->simulateClick($campaign, $url, $subscriber1) + ->simulateClick($campaign, $url, $subscriber2) + ->simulateClick($campaign, $url, $subscriber2); + + dispatch_now(new CalculateStatisticsJob($campaign)); + + $campaignLink = CampaignLink::where('url', $url)->first(); + + $this->assertEquals(3, $campaignLink->click_count); + $this->assertEquals(2, $campaignLink->unique_click_count); + } + + /** @test */ + public function it_can_calculate_statistics_regarding_bounces() + { + $campaign = (new CampaignFactory())->withSubscriberCount(3)->create([ + 'html' => 'Spatie', + 'track_clicks' => true, + ]); + + dispatch(new SendCampaignJob($campaign)); + + $campaign->sends()->first()->registerBounce(); + + dispatch_now(new CalculateStatisticsJob($campaign)); + + $this->assertEquals(1, $campaign->bounce_count); + $this->assertEquals(33, $campaign->bounce_rate); + } + + /** @test */ + public function the_queue_of_the_calculate_statistics_job_can_be_configured() + { + Queue::fake(); + config()->set('mailcoach.perform_on_queue.calculate_statistics_job', 'custom-queue'); + + $campaign = factory(Campaign::class)->create(); + dispatch(new CalculateStatisticsJob($campaign)); + Queue::assertPushed(CalculateStatisticsJob::class); + } + + protected function simulateOpen(Collection $sends) + { + $sends->each(function (Send $send) { + $send->registerOpen(); + TestTime::addSeconds(10); + }); + + return $this; + } + + + public function simulateClick(Campaign $campaign, string $url, $subscribers) + { + if ($subscribers instanceof Model) { + $subscribers = collect([$subscribers]); + } + + collect($subscribers)->each(function (Subscriber $subscriber) use ($campaign, $url) { + Send::query() + ->where('campaign_id', $campaign->id) + ->where('subscriber_id', $subscriber->id) + ->first() + ->registerClick($url); + }); + return $this; + } +} diff --git a/Jobs/RetrySendingFailedSendsJobTest.php b/Jobs/RetrySendingFailedSendsJobTest.php new file mode 100644 index 0000000..2f26bfc --- /dev/null +++ b/Jobs/RetrySendingFailedSendsJobTest.php @@ -0,0 +1,45 @@ +create(['html' => 'test']); + + $john = Subscriber::createWithEmail('john@example.com')->subscribeTo($campaign->emailList); + $jane = Subscriber::createWithEmail('jane@example.com')->subscribeTo($campaign->emailList); + + config()->set('mailcoach.actions.personalize_html', FailingPersonalizeHtmlForJohnAction::class); + dispatch(new SendCampaignJob($campaign)); + + Mail::assertSent(CampaignMail::class, 1); + Mail::assertSent(CampaignMail::class, fn (CampaignMail $mail) => $mail->hasTo($jane->email)); + + $failedSends = $campaign->sends()->failed()->get(); + + $this->assertCount(1, $failedSends); + $this->assertEquals($john->email, $failedSends->first()->subscriber->email); + $this->assertEquals('Could not personalize html', $failedSends->first()->failure_reason); + + config()->set('mailcoach.actions.personalize_html', PersonalizeHtmlAction::class); + dispatch(new RetrySendingFailedSendsJob($campaign)); + + Mail::assertSent(CampaignMail::class, 2); + Mail::assertSent(CampaignMail::class, fn (CampaignMail $mail) => $mail->hasTo($john->email)); + } +} diff --git a/Jobs/SendCampaignJobTest.php b/Jobs/SendCampaignJobTest.php new file mode 100644 index 0000000..06dc8b7 --- /dev/null +++ b/Jobs/SendCampaignJobTest.php @@ -0,0 +1,133 @@ +campaign = (new CampaignFactory()) + ->withSubscriberCount(3) + ->create(); + + Mail::fake(); + + Event::fake(); + } + + /** @test */ + public function it_can_send_a_campaign() + { + dispatch(new SendCampaignJob($this->campaign)); + + Mail::assertSent(CampaignMail::class, 3); + + Event::assertDispatched(CampaignSentEvent::class, function (CampaignSentEvent $event) { + $this->assertEquals($this->campaign->id, $event->campaign->id); + + return true; + }); + + $this->campaign->refresh(); + $this->assertEquals(CampaignStatus::SENT, $this->campaign->status); + $this->assertEquals(3, $this->campaign->sent_to_number_of_subscribers); + } + + /** @test */ + public function it_will_not_create_mailcoach_sends_if_they_already_have_been_created() + { + $emailList = factory(EmailList::class)->create(); + + $campaign = factory(Campaign::class)->create([ + 'email_list_id' => $emailList->id, + ]); + + $subscriber = factory(Subscriber::class)->create([ + 'email_list_id' => $emailList->id, + 'subscribed_at' => now(), + ]); + + factory(Send::class)->create([ + 'subscriber_id' => $subscriber->id, + 'campaign_id' => $campaign->id, + ]); + + dispatch(new SendCampaignJob($campaign)); + + $this->assertCount(1, Send::all()); + } + + /** @test */ + public function a_campaign_that_was_sent_will_not_be_sent_again() + { + $this->assertFalse($this->campaign->wasAlreadySent()); + dispatch(new SendCampaignJob($this->campaign)); + $this->assertTrue($this->campaign->refresh()->wasAlreadySent()); + Mail::assertSent(CampaignMail::class, 3); + + dispatch(new SendCampaignJob($this->campaign)); + Mail::assertSent(CampaignMail::class, 3); + Event::assertDispatched(CampaignSentEvent::class, 1); + } + + /** @test */ + public function it_will_prepare_the_webview() + { + $this->campaign->update([ + 'html' => 'my html', + 'webview_html' => null, + ]); + + dispatch(new SendCampaignJob($this->campaign)); + + $this->assertMatchesHtmlSnapshotWithoutWhitespace($this->campaign->refresh()->webview_html); + } + + /** @test */ + public function it_will_not_send_invalid_html() + { + $this->campaign->update([ + 'track_clicks' => true, + 'html' => '<<>><<', + ]); + + $this->expectException(CouldNotSendCampaign::class); + + dispatch(new SendCampaignJob($this->campaign)); + } + + /** @test */ + public function the_queue_of_the_send_campaign_job_can_be_configured() + { + Queue::fake(); + + config()->set('mailcoach.perform_on_queue.send_campaign_job', 'custom-queue'); + + $campaign = factory(Campaign::class)->create(); + dispatch(new SendCampaignJob($campaign)); + + Queue::assertPushedOn('custom-queue', SendCampaignJob::class); + } +} diff --git a/Jobs/SendMailJobTest.php b/Jobs/SendMailJobTest.php new file mode 100644 index 0000000..9c69d3e --- /dev/null +++ b/Jobs/SendMailJobTest.php @@ -0,0 +1,75 @@ +create(); + + dispatch(new SendMailJob($pendingSend)); + + Mail::assertSent(CampaignMail::class, function (CampaignMail $mail) use ($pendingSend) { + $this->assertEquals($pendingSend->campaign->subject, $mail->subject); + $this->assertTrue($mail->hasTo($pendingSend->subscriber->email)); + + return true; + }); + } + + /** @test */ + public function it_will_not_resend_a_mail_that_has_already_been_sent() + { + $pendingSend = factory(Send::class)->create(); + + $this->assertFalse($pendingSend->wasAlreadySent()); + + dispatch(new SendMailJob($pendingSend)); + + $this->assertTrue($pendingSend->refresh()->wasAlreadySent()); + Mail::assertSent(CampaignMail::class, 1); + + dispatch(new SendMailJob($pendingSend)); + Mail::assertSent(CampaignMail::class, 1); + } + + /** @test */ + public function the_queue_of_the_send_mail_job_can_be_configured() + { + Queue::fake(); + config()->set('mailcoach.perform_on_queue.send_mail_job', 'custom-queue'); + + $pendingSend = factory(Send::class)->create(); + dispatch(new SendMailJob($pendingSend)); + Queue::assertPushedOn('custom-queue', SendMailJob::class); + } + + /** @test */ + public function it_can_use_a_custom_mailable() + { + $pendingSend = factory(Send::class)->create(); + + $pendingSend->campaign->useMailable(TestCampaignMail::class); + + dispatch(new SendMailJob($pendingSend)); + + Mail::assertSent(TestCampaignMail::class, 1); + } +} diff --git a/Jobs/SendTestMailJobTest.php b/Jobs/SendTestMailJobTest.php new file mode 100644 index 0000000..4bce534 --- /dev/null +++ b/Jobs/SendTestMailJobTest.php @@ -0,0 +1,46 @@ +create([ + 'html' => 'my html', + ]); + + $email = 'john@example.com'; + + dispatch(new SendTestMailJob($campaign, $email)); + + Mail::assertSent(CampaignMail::class, function (CampaignMail $mail) use ($email, $campaign) { + $this->assertEquals($campaign->subject, $mail->subject); + + $this->assertTrue($mail->hasTo($email)); + + return true; + }); + } + + /** @test */ + public function the_queue_of_the_send_test_mail_job_can_be_configured() + { + Queue::fake(); + config()->set('mailcoach.perform_on_queue.send_test_mail_job', 'custom-queue'); + + $campaign = factory(Campaign::class)->create(); + dispatch(new SendTestMailJob($campaign, 'john@example.com')); + Queue::assertPushedOn('custom-queue', SendTestMailJob::class); + } +} diff --git a/Jobs/__snapshots__/SendCampaignJobTest__it_will_prepare_the_webview__1.html b/Jobs/__snapshots__/SendCampaignJobTest__it_will_prepare_the_webview__1.html new file mode 100644 index 0000000..4e64633 --- /dev/null +++ b/Jobs/__snapshots__/SendCampaignJobTest__it_will_prepare_the_webview__1.html @@ -0,0 +1,5 @@ + + +

-//W3C//DTDHTML4.0Transitional//EN""http://www.w3.org/TR/REC-html40/loose.dtd">

+

myhtml

+ diff --git a/Mails/CampaignMailTest.php b/Mails/CampaignMailTest.php new file mode 100644 index 0000000..246da34 --- /dev/null +++ b/Mails/CampaignMailTest.php @@ -0,0 +1,31 @@ +create(); + + $campaignMailable = (new CampaignMail()) + ->setCampaign($send->campaign) + ->setSend($send) + ->setHtmlContent('dummy content') + ->subject('test mail'); + + Mail::to('john@example.com')->send($campaignMailable); + + $this->assertStringEndsWith( + '@swift.generated', + $send->refresh()->transport_message_id + ); + } +} diff --git a/Mails/CampaignSentMailTest.php b/Mails/CampaignSentMailTest.php new file mode 100644 index 0000000..91986bf --- /dev/null +++ b/Mails/CampaignSentMailTest.php @@ -0,0 +1,79 @@ +emailList = factory(EmailList::class)->create(); + + $this->campaign = factory(Campaign::class)->create([ + 'email_list_id' => $this->emailList->id, + ]); + } + + /** @test */ + public function when_a_campaign_is_sent_it_will_send_a_mail() + { + Mail::fake(); + + $this->emailList->update([ + 'report_recipients' => 'john@example.com,jane@example.com', + 'report_campaign_sent' => true, + ]); + + event(new CampaignSentEvent($this->campaign)); + + Mail::assertQueued(CampaignSentMail::class, function (CampaignSentMail $mail) { + return $mail->hasTo('john@example.com'); + }); + + Mail::assertQueued(CampaignSentMail::class, function (CampaignSentMail $mail) { + return $mail->hasTo('jane@example.com'); + }); + } + + /** @test */ + public function it_will_not_send_a_campaign_sent_mail_if_it_is_not_enabled() + { + Mail::fake(); + + $this->emailList->update([ + 'report_campaign_sent' => false, + ]); + + event(new CampaignSentEvent($this->campaign)); + + Mail::assertNotQueued(CampaignSentMail::class); + } + + /** @test */ + public function it_will_not_send_a_campaign_sent_mail_when_no_destination_is_set() + { + Mail::fake(); + + event(new CampaignSentEvent($this->campaign)); + + Mail::assertNotQueued(CampaignSentMail::class); + } + + /** @test */ + public function the_content_of_the_campaign_sent_mail_is_valid() + { + $this->assertIsString((new CampaignSentMail($this->campaign))->render()); + } +} diff --git a/Mails/CampaignSummaryMailTest.php b/Mails/CampaignSummaryMailTest.php new file mode 100644 index 0000000..5e5d8be --- /dev/null +++ b/Mails/CampaignSummaryMailTest.php @@ -0,0 +1,102 @@ +emailList = factory(EmailList::class)->create(); + + $this->campaign = factory(Campaign::class)->create([ + 'email_list_id' => $this->emailList->id, + 'sent_at' => now(), + ]); + + $this->emailList->update([ + 'report_recipients' => 'john@example.com,jane@example.com', + 'report_campaign_summary' => true, + ]); + } + + /** @test */ + public function after_a_day_it_will_sent_a_summary() + { + Mail::fake(); + + $this->artisan(SendCampaignSummaryMailCommand::class); + Mail::assertNotQueued(CampaignSummaryMail::class); + + TestTime::addDay(); + TestTime::subSecond(); + + $this->artisan(SendCampaignSummaryMailCommand::class); + Mail::assertNotQueued(CampaignSummaryMail::class); + + TestTime::addSecond(); + $this->artisan(SendCampaignSummaryMailCommand::class); + + Mail::assertQueued(CampaignSummaryMail::class, function (CampaignSummaryMail $mail) { + return $mail->hasTo('john@example.com'); + }); + + Mail::assertQueued(CampaignSummaryMail::class, function (CampaignSummaryMail $mail) { + return $mail->hasTo('jane@example.com'); + }); + + $this->assertEquals( + now()->format('YmdHis'), + $this->campaign->refresh()->summary_mail_sent_at->format('YmdHis') + ); + } + + /** @test */ + public function it_will_not_send_a_summary_mail_if_it_was_already_sent() + { + Mail::fake(); + + $this->campaign->update(['summary_mail_sent_at' => now()]); + + TestTime::addDay(); + $this->artisan(SendCampaignSummaryMailCommand::class); + Mail::assertNotQueued(CampaignSummaryMail::class); + } + + /** @test */ + public function it_will_not_report_a_summary_if_it_is_not_enabled_on_the_list() + { + Mail::fake(); + + $this->emailList->update([ + 'report_campaign_summary' => false, + ]); + + TestTime::addDay(); + + $this->artisan(SendCampaignSummaryMailCommand::class); + + Mail::assertNotQueued(CampaignSummaryMail::class); + } + + /** @test */ + public function the_content_of_the_campaign_summary_mail_is_valid() + { + $this->assertIsString((new CampaignSummaryMail($this->campaign))->render()); + } +} diff --git a/Mails/ConfirmSubscriptionMailTest.php b/Mails/ConfirmSubscriptionMailTest.php new file mode 100644 index 0000000..1ce8f19 --- /dev/null +++ b/Mails/ConfirmSubscriptionMailTest.php @@ -0,0 +1,94 @@ +emailList = factory(EmailList::class)->create([ + 'requires_confirmation' => true, + 'name' => 'my newsletter', + ]); + } + + /** @test */ + public function the_confirmation_mail_has_a_default_subject() + { + Mail::fake(); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertQueued(ConfirmSubscriberMail::class, function (ConfirmSubscriberMail $mail) { + $mail->build(); + + $this->assertStringContainsString('Confirm', $mail->subject); + + return true; + }); + } + + /** @test */ + public function the_subject_of_the_confirmation_mail_can_be_customized() + { + Mail::fake(); + + $this->emailList->update(['confirmation_mail_subject' => 'Hello ::subscriber.first_name::, welcome to ::list.name::']); + + Subscriber::createWithEmail('john@example.com', ['first_name' => 'John'])->subscribeTo($this->emailList); + + Mail::assertQueued(ConfirmSubscriberMail::class, function (ConfirmSubscriberMail $mail) { + $mail->build(); + $this->assertEquals('Hello John, welcome to my newsletter', $mail->subject); + + return true; + }); + } + + /** @test */ + public function the_confirmation_mail_has_default_content() + { + $subscriber = Subscriber::createWithEmail('john@example.com', ['first_name' => 'John'])->subscribeTo($this->emailList); + + $content = (new ConfirmSubscriberMail($subscriber))->render(); + + $this->assertStringContainsString('confirm', $content); + } + + /** @test */ + public function the_confirmation_mail_can_have_custom_content() + { + Subscriber::$fakeUuid = 'my-uuid'; + + $this->emailList->update(['confirmation_mail_content' => 'Hi ::subscriber.first_name::, press ::confirmUrl:: to subscribe to ::list.name::']); + + $subscriber = Subscriber::createWithEmail('john@example.com', ['first_name' => 'John'])->subscribeTo($this->emailList); + + $content = (new ConfirmSubscriberMail($subscriber))->render(); + + $this->assertStringContainsString('Hi John, press http://localhost/mailcoach/confirm-subscription/my-uuid to subscribe to my newsletter', $content); + } + + /** @test */ + public function it_can_use_custom_welcome_mailable() + { + Mail::fake(); + + $this->emailList->update(['confirmation_mailable_class' => CustomConfirmSubscriberMail::class]); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertQueued(CustomConfirmSubscriberMail::class); + } +} diff --git a/Mails/EmailListSummaryMailTest.php b/Mails/EmailListSummaryMailTest.php new file mode 100644 index 0000000..88cbbd1 --- /dev/null +++ b/Mails/EmailListSummaryMailTest.php @@ -0,0 +1,93 @@ +emailList = factory(EmailList::class)->create([ + 'report_recipients' => 'john@example.com,jane@example.com', + 'report_email_list_summary' => true, + ]); + } + + /** @test */ + public function it_can_send_the_email_list_summary() + { + Mail::fake(); + + $this->artisan(SendEmailListSummaryMailCommand::class); + Mail::assertQueued(EmailListSummaryMail::class, function (EmailListSummaryMail $mail) { + $this->assertEquals('2019-01-01 00:00:00', $mail->summaryStartDateTime->toDateTimeString()); + + return true; + }); + $this->assertEquals('2019-01-01 00:00:00', $this->emailList->refresh()->email_list_summary_sent_at->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_will_not_send_the_email_list_summary_mail_if_it_is_not_enabled() + { + Mail::fake(); + + $this->emailList->update(['report_email_list_summary' => false]); + + $this->artisan(SendEmailListSummaryMailCommand::class); + Mail::assertNotQueued(EmailListSummaryMail::class); + } + + /** @test */ + public function it_will_not_send_an_email_list_summary_twice_on_one_day() + { + Mail::fake(); + + $this->emailList->update([ + 'email_list_summary_sent_at' => now(), + ]); + + $this->artisan(SendEmailListSummaryMailCommand::class); + + Mail::assertNotQueued(EmailListSummaryMail::class); + } + + /** @test */ + public function it_will_send_the_email_list_summary_starting_from_the_previous_sent_date() + { + Mail::fake(); + + TestTime::addWeek(); + + $this->emailList->update([ + 'email_list_summary_sent_at' => now(), + ]); + + TestTime::addWeek(); + + $this->artisan(SendEmailListSummaryMailCommand::class); + Mail::assertQueued(EmailListSummaryMail::class, function (EmailListSummaryMail $mail) { + $this->assertEquals('2019-01-08 00:00:00', $mail->summaryStartDateTime->toDateTimeString()); + + return true; + }); + } + + /** @test */ + public function the_content_of_the_email_list_summary_mail_is_valid() + { + $this->assertIsString((new EmailListSummaryMail($this->emailList, now()))->render()); + } +} diff --git a/Mails/ImportSubscribersResultMailTest.php b/Mails/ImportSubscribersResultMailTest.php new file mode 100644 index 0000000..a1e5e12 --- /dev/null +++ b/Mails/ImportSubscribersResultMailTest.php @@ -0,0 +1,18 @@ +create(); + + $this->assertIsString((new ImportSubscribersResultMail($subscriberImport))->render()); + } +} diff --git a/Mails/WelcomeMailTest.php b/Mails/WelcomeMailTest.php new file mode 100644 index 0000000..fa85c83 --- /dev/null +++ b/Mails/WelcomeMailTest.php @@ -0,0 +1,133 @@ +emailList = factory(EmailList::class)->create([ + 'name' => 'my newsletter', + 'requires_confirmation' => false, + 'send_welcome_mail' => true, + ]); + } + + /** @test */ + public function if_will_send_a_welcome_mail_when_a_subscriber_has_subscribed() + { + Mail::fake(); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertQueued(WelcomeMail::class); + } + + /** @test */ + public function it_will_not_send_a_welcome_mail_if_it_is_not_enabled_on_the_email_list() + { + Mail::fake(); + + $this->emailList->update(['send_welcome_mail' => false]); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertNothingQueued(); + } + + /** @test */ + public function it_will_send_a_welcome_mail_when_a_subscribed_gets_confirmed() + { + Mail::fake(); + + $this->emailList->update(['requires_confirmation' => true]); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertNotQueued(WelcomeMail::class); + + $subscriber->confirm(); + + Mail::assertQueued(WelcomeMail::class); + } + + /** @test */ + public function the_welcome_mail_has_a_default_subject() + { + Mail::fake(); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertQueued(WelcomeMail::class, function (WelcomeMail $mail) { + $mail->build(); + + $this->assertStringContainsString('Welcome', $mail->subject); + + return true; + }); + } + + /** @test */ + public function the_subject_of_the_welcome_mail_can_be_customized() + { + Mail::fake(); + + $this->emailList->update(['welcome_mail_subject' => 'Hello ::subscriber.first_name::, welcome to ::list.name::']); + + Subscriber::createWithEmail('john@example.com', ['first_name' => 'John'])->subscribeTo($this->emailList); + + Mail::assertQueued(WelcomeMail::class, function (WelcomeMail $mail) { + $mail->build(); + $this->assertEquals('Hello John, welcome to my newsletter', $mail->subject); + + return true; + }); + } + + /** @test */ + public function the_welcome_mail_has_default_content() + { + $subscriber = Subscriber::createWithEmail('john@example.com', ['first_name' => 'John'])->subscribeTo($this->emailList); + + $content = (new WelcomeMail($subscriber))->render(); + + $this->assertStringContainsString('You are now subscribed', $content); + } + + /** @test */ + public function the_welcome_mail_can_have_custom_content() + { + Subscriber::$fakeUuid = 'my-uuid'; + + $this->emailList->update(['welcome_mail_content' => 'Hi ::subscriber.first_name::, welcome to ::list.name::. Here is a link to unsubscribe ::unsubscribeUrl::']); + + $subscriber = Subscriber::createWithEmail('john@example.com', ['first_name' => 'John'])->subscribeTo($this->emailList); + + $content = (new WelcomeMail($subscriber))->render(); + + $this->assertStringContainsString('Hi John, welcome to my newsletter. Here is a link to unsubscribe http://localhost/mailcoach/unsubscribe/my-uuid', $content); + } + + /** @test */ + public function it_can_use_custom_welcome_mailable() + { + Mail::fake(); + + $this->emailList->update(['welcome_mailable_class' => CustomWelcomeMail::class]); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + + Mail::assertQueued(CustomWelcomeMail::class); + } +} diff --git a/Models/CampaignTest.php b/Models/CampaignTest.php new file mode 100644 index 0000000..9825041 --- /dev/null +++ b/Models/CampaignTest.php @@ -0,0 +1,431 @@ +campaign = Campaign::create()->refresh(); + } + + /** @test */ + public function the_default_status_is_draft() + { + $this->assertEquals(CampaignStatus::DRAFT, $this->campaign->status); + } + + /** @test */ + public function it_can_set_a_from_email() + { + $this->campaign->from('sender@example.com'); + + $this->assertEquals('sender@example.com', $this->campaign->from_email); + } + + /** @test */ + public function it_can_set_both_a_from_email_and_a_from_name() + { + $this->campaign->from('sender@example.com', 'Sender name'); + + $this->assertEquals('sender@example.com', $this->campaign->from_email); + $this->assertEquals('Sender name', $this->campaign->from_name); + } + + /** @test */ + public function it_can_be_marked_to_track_opens() + { + $this->assertFalse($this->campaign->track_opens); + + $this->campaign->trackOpens(); + + $this->assertTrue($this->campaign->refresh()->track_opens); + } + + /** @test */ + public function it_can_be_marked_to_track_clicks() + { + $this->assertFalse($this->campaign->track_clicks); + + $this->campaign->trackClicks(); + + $this->assertTrue($this->campaign->refresh()->track_clicks); + } + + /** @test */ + public function it_can_add_a_subject() + { + $this->assertNull($this->campaign->subject); + + $this->campaign->subject('hello'); + + $this->assertEquals('hello', $this->campaign->refresh()->subject); + } + + /** @test */ + public function it_can_add_a_list() + { + $list = factory(EmailList::class)->create(); + + $this->campaign->to($list); + + $this->assertEquals($list->id, $this->campaign->refresh()->email_list_id); + } + + /** @test */ + public function it_can_be_sent() + { + $list = factory(EmailList::class)->create(); + + $campaign = Campaign::create() + ->from('test@example.com') + ->subject('test') + ->content('my content') + ->to($list) + ->send(); + + Queue::assertPushed(SendCampaignJob::class, function (SendCampaignJob $job) use ($campaign) { + $this->assertEquals($campaign->id, $job->campaign->id); + + return true; + }); + } + + /** @test */ + public function it_has_a_shorthand_to_set_the_list_and_send_it_in_one_go() + { + $list = factory(EmailList::class)->create(); + + $campaign = Campaign::create() + ->from('test@example.com') + ->content('my content') + ->subject('test') + ->sendTo($list); + + $this->assertEquals($list->id, $campaign->refresh()->email_list_id); + + Queue::assertPushed(SendCampaignJob::class, function (SendCampaignJob $job) use ($campaign) { + $this->assertEquals($campaign->id, $job->campaign->id); + + return true; + }); + } + + /** @test */ + public function a_mailable_can_be_set() + { + /** @var \Spatie\Mailcoach\Models\Campaign $campaign */ + $campaign = Campaign::create()->useMailable(TestCampaignMail::class); + + $this->assertEquals(TestCampaignMail::class, $campaign->mailable_class); + } + + /** @test */ + public function it_will_throw_an_exception_when_use_an_invalid_mailable_class() + { + $this->expectException(CouldNotSendCampaign::class); + + Campaign::create()->useMailable(static::class); + } + + /** @test */ + public function a_segment_can_be_set() + { + $campaign = Campaign::create() + ->segment(TestCustomQueryOnlyShouldSendToJohn::class); + + $this->assertEquals(TestCustomQueryOnlyShouldSendToJohn::class, $campaign->segment_class); + } + + /** @test */ + public function it_will_throw_an_exception_when_use_an_invalid_segment_class() + { + $this->expectException(CouldNotSendCampaign::class); + + Campaign::create()->segment(static::class); + } + + /** @test */ + public function html_and_content_are_not_required_when_sending_a_mailable() + { + Bus::fake(); + + $list = factory(EmailList::class)->create(); + + Campaign::create() + ->from('test@example.com') + ->content('my content') + ->subject('test') + ->sendTo($list); + + Bus::assertDispatched(SendCampaignJob::class); + } + + /** @test */ + public function it_can_use_the_default_from_email_and_name_set_on_the_email_list() + { + Bus::fake(); + + $list = factory(EmailList::class)->create([ + 'default_from_email' => 'defaultEmailList@example.com', + 'default_from_name' => 'List name', + ]); + + Campaign::create() + ->content('my content') + ->subject('test') + ->sendTo($list); + + Bus::assertDispatched(SendCampaignJob::class, function (SendCampaignJob $job) { + $this->assertEquals('defaultEmailList@example.com', $job->campaign->from_email); + $this->assertEquals('List name', $job->campaign->from_name); + + return true; + }); + } + + /** @test */ + public function it_will_prefer_the_email_and_from_name_from_the_campaign_over_the_defaults_set_on_the_email_list() + { + Bus::fake(); + + $list = factory(EmailList::class)->create([ + 'default_from_email' => 'defaultEmailList@example.com', + 'default_from_name' => 'List name', + ]); + + Campaign::create() + ->content('my content') + ->subject('test') + ->from('campaign@example.com', 'campaign from name') + ->sendTo($list); + + Bus::assertDispatched(SendCampaignJob::class, function (SendCampaignJob $job) { + $this->assertEquals('campaign@example.com', $job->campaign->from_email); + $this->assertEquals('campaign from name', $job->campaign->from_name); + + return true; + }); + } + + /** @test */ + public function it_has_a_scope_that_can_get_campaigns_sent_in_a_certain_period() + { + $sentAt1430 = CampaignFactory::createSentAt('2019-01-01 14:30:00'); + $sentAt1530 = CampaignFactory::createSentAt('2019-01-01 15:30:00'); + $sentAt1630 = CampaignFactory::createSentAt('2019-01-01 16:30:00'); + $sentAt1730 = CampaignFactory::createSentAt('2019-01-01 17:30:00'); + + $campaigns = Campaign::sentBetween( + Carbon::createFromFormat('Y-m-d H:i:s', '2019-01-01 13:30:00'), + Carbon::createFromFormat('Y-m-d H:i:s', '2019-01-01 17:30:00'), + )->get(); + + $this->assertEquals( + [$sentAt1430->id, $sentAt1530->id, $sentAt1630->id], + $campaigns->pluck('id')->values()->toArray(), + ); + } + + /** @test */ + public function it_can_send_out_a_test_email() + { + Bus::fake(); + + $email = 'john@example.com'; + + $this->campaign->sendTestMail($email); + + Bus::assertDispatched(SendTestMailJob::class, function (SendTestMailJob $job) use ($email) { + $this->assertEquals($this->campaign->id, $job->campaign->id); + $this->assertEquals($email, $job->email); + + return true; + }); + } + + /** @test */ + public function it_can_send_out_multiple_test_emails_at_once() + { + Bus::fake(); + + $this->campaign->sendTestMail(['john@example.com', 'paul@example.com']); + + Bus::assertDispatched(SendTestMailJob::class, fn (SendTestMailJob $job) => $job->email === 'john@example.com'); + + Bus::assertDispatched(SendTestMailJob::class, fn (SendTestMailJob $job) => $job->email === 'paul@example.com'); + } + + /** @test */ + public function it_can_dispatch_a_job_to_recalculate_statistics() + { + Bus::fake(); + + $this->campaign->dispatchCalculateStatistics(); + + Bus::assertDispatched(CalculateStatisticsJob::class, 1); + } + + /** @test */ + public function it_will_not_dispatch_the_recalculation_job_twice() + { + Bus::fake(); + + $this->campaign->dispatchCalculateStatistics(); + $this->campaign->dispatchCalculateStatistics(); + + Bus::assertDispatched(CalculateStatisticsJob::class, 1); + } + + /** @test */ + public function it_can_dispatch_the_recalculation_job_again_after_the_previous_job_has_run() + { + Bus::fake(); + + $this->campaign->dispatchCalculateStatistics(); + + (new CalculateStatisticsJob($this->campaign))->handle(); + + $this->campaign->dispatchCalculateStatistics(); + + Bus::assertDispatched(CalculateStatisticsJob::class, 2); + } + + /** @test */ + public function it_has_scopes_to_get_campaigns_in_various_states() + { + Campaign::truncate(); + + $draftCampaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + ]); + + $scheduledInThePastCampaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + 'scheduled_at' => now()->subSecond() + ]); + + $scheduledNowCampaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + 'scheduled_at' => now() + ]); + + $scheduledInFutureCampaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::DRAFT, + 'scheduled_at' => now()->addSecond() + ]); + + $sendingCampaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::SENDING, + ]); + + $sentCampaign = factory(Campaign::class)->create([ + 'status' => CampaignStatus::SENT, + ]); + + $this->assertModels([ + $draftCampaign + ], Campaign::draft()->get()); + + $this->assertModels([ + $scheduledInThePastCampaign, + $scheduledNowCampaign, + $scheduledInFutureCampaign, + ], Campaign::scheduled()->get()); + + $this->assertModels([ + $scheduledInThePastCampaign, + $scheduledNowCampaign, + ], Campaign::shouldBeSentNow()->get()); + + $this->assertModels([ + $sendingCampaign, + $sentCampaign, + ], Campaign::sendingOrSent()->get()); + } + + /** @test */ + public function it_can_send_determine_if_it_has_troubles_sending_out_mails() + { + TestTime::freeze(); + + $this->assertFalse($this->campaign->hasTroublesSendingOutMails()); + + $this->campaign->update([ + 'status' => CampaignStatus::SENDING, + 'last_modified_at' => now(), + ]); + + factory(Send::class)->create([ + 'campaign_id' => $this->campaign->id, + 'sent_at' => now(), + ]); + + $send = factory(Send::class)->create([ + 'campaign_id' => $this->campaign->id, + 'sent_at' => null, + ]); + + $this->assertFalse($this->campaign->hasTroublesSendingOutMails()); + + TestTime::addHour(); + $this->assertTrue($this->campaign->hasTroublesSendingOutMails()); + + $send->update(['sent_at' => now()]); + $this->assertFalse($this->campaign->hasTroublesSendingOutMails()); + } + + /** @test */ + public function it_can_inline_the_styles_of_the_html() + { + /** @var Campaign $campaign */ + $campaign = factory(Campaign::class)->create(['html' => '<< + + My body + ' + + ]); + + $this->assertMatchesHtmlSnapshotWithoutWhitespace($campaign->htmlWithInlinedCss()); + } + + private function assertModels(array $expectedModels, Collection $actualModels) + { + $this->assertEquals(count($expectedModels), $actualModels->count()); + $this->assertEquals(collect($expectedModels)->pluck('id')->toArray(), $actualModels->pluck('id')->toArray()); + } +} diff --git a/Models/EmailListTest.php b/Models/EmailListTest.php new file mode 100644 index 0000000..96408e7 --- /dev/null +++ b/Models/EmailListTest.php @@ -0,0 +1,174 @@ +emailList = factory(EmailList::class)->create(); + } + + /** @test */ + public function it_can_add_a_subscriber_to_a_list() + { + $subscriber = $this->emailList->subscribe('john@example.com'); + + $this->assertEquals('john@example.com', $subscriber->email); + } + + /** @test */ + public function it_can_add_a_subscriber_with_extra_attributes_to_a_list() + { + $attributes = [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'extra_attributes' => ['key 1' => 'Value 1', 'key 2' => 'Value 2'], + ]; + + $subscriber = $this->emailList->subscribe('john@example.com', $attributes)->refresh(); + + $this->assertEquals('john@example.com', $subscriber->email); + $this->assertEquals('John', $subscriber->first_name); + $this->assertEquals('Doe', $subscriber->last_name); + $this->assertEquals($attributes['extra_attributes'], $subscriber->extra_attributes->all()); + } + + /** @test */ + public function when_adding_someone_that_was_already_subscribed_no_new_subscription_will_be_created() + { + $this->emailList->subscribe('john@example.com'); + $this->emailList->subscribe('john@example.com'); + + $this->assertEquals(1, Subscriber::count()); + } + + /** @test */ + public function it_can_unsubscribe_someone() + { + $this->emailList->subscribe('john@example.com'); + + $this->assertTrue($this->emailList->unsubscribe('john@example.com')); + $this->assertFalse($this->emailList->unsubscribe('non-existing-subscriber@example.com')); + + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, Subscriber::first()->status); + } + + /** @test */ + public function it_can_get_all_subscribers_that_are_subscribed() + { + $this->emailList->subscribe('john@example.com'); + $this->emailList->subscribe('jane@example.com'); + $this->emailList->unsubscribe('john@example.com'); + + $subscribers = $this->emailList->subscribers; + $this->assertCount(1, $subscribers); + $this->assertEquals('jane@example.com', $subscribers->first()->email); + + $subscribers = $this->emailList->allSubscribers; + $this->assertCount(2, $subscribers); + } + + /** @test */ + public function it_can_subscribe_someone_immediately_even_if_double_opt_in_is_enabled() + { + Mail::fake(); + + $this->emailList->update(['requires_confirmation' => true]); + + $this->emailList->subscribeSkippingConfirmation('john@example.com'); + + Mail::assertNothingQueued(); + + $this->assertEquals('john@example.com', $this->emailList->subscribers->first()->email); + } + + /** @test */ + public function it_cannot_subscribe_an_invalid_email() + { + $this->expectException(CouldNotSubscribe::class); + + $this->emailList->subscribe('invalid-email'); + } + + /** @test */ + public function it_can_get_the_status_of_a_subscription() + { + $this->assertNull($this->emailList->getSubscriptionStatus('john@example.com')); + + $this->emailList->subscribe('john@example.com'); + + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $this->emailList->getSubscriptionStatus('john@example.com')); + } + + /** @test */ + public function it_can_summarize_an_email_list() + { + TestTime::freeze(); + + $this->assertEquals([ + 'total_number_of_subscribers' => 0, + 'total_number_of_subscribers_gained' => 0, + 'total_number_of_unsubscribes_gained' => 0, + ], $this->emailList->summarize(now()->subWeek())); + + $subscriber = Subscriber::createWithEmail('john@example.com') + ->skipConfirmation() + ->subscribeTo($this->emailList); + + $this->assertEquals([ + 'total_number_of_subscribers' => 1, + 'total_number_of_subscribers_gained' => 1, + 'total_number_of_unsubscribes_gained' => 0, + ], $this->emailList->summarize(now()->subWeek())); + + $subscriber->unsubscribe(); + + $this->assertEquals([ + 'total_number_of_subscribers' => 0, + 'total_number_of_subscribers_gained' => 1, + 'total_number_of_unsubscribes_gained' => 1, + ], $this->emailList->summarize(now()->subWeek())); + + Subscriber::createWithEmail('jane@example.com') + ->skipConfirmation() + ->subscribeTo($this->emailList); + + $this->assertEquals([ + 'total_number_of_subscribers' => 1, + 'total_number_of_subscribers_gained' => 2, + 'total_number_of_unsubscribes_gained' => 1, + ], $this->emailList->summarize(now()->subWeek())); + + TestTime::addWeek(); + + $this->assertEquals([ + 'total_number_of_subscribers' => 1, + 'total_number_of_subscribers_gained' => 0, + 'total_number_of_unsubscribes_gained' => 0, + ], $this->emailList->summarize(now()->subWeek())); + + Subscriber::createWithEmail('paul@example.com') + ->skipConfirmation() + ->subscribeTo($this->emailList); + + $this->assertEquals([ + 'total_number_of_subscribers' => 2, + 'total_number_of_subscribers_gained' => 1, + 'total_number_of_unsubscribes_gained' => 0, + ], $this->emailList->summarize(now()->subWeek())); + } +} diff --git a/Models/SegmentTest.php b/Models/SegmentTest.php new file mode 100644 index 0000000..b70d506 --- /dev/null +++ b/Models/SegmentTest.php @@ -0,0 +1,237 @@ +emailList = factory(EmailList::class)->create(); + } + + /** @test */ + public function it_can_build_a_query_to_get_subscribers_with_certain_tags() + { + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithOneTag = $this->createSubscriberWithTags('oneTag@example.com', ['tagA']); + $subscriberWithManyTags = $this->createSubscriberWithTags('multipleTags@example.com', ['tagA', 'tagB']); + + $subscribers = (TagSegment::create(['name' => 'testSegment', 'email_list_id' => $this->emailList->id]) + ->syncPositiveTags(['tagA']) + ->getSubscribersQuery() + ->get()); + + $this->assertArrayContainsSubscribers([ + $subscriberWithOneTag, + $subscriberWithManyTags, + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_subscribers_having_any_of_multiple_tags() + { + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithOneTag = $this->createSubscriberWithTags('oneTag@example.com', ['tagA']); + $subscriberWithManyTags = $this->createSubscriberWithTags('multipleTags@example.com', ['tagA', 'tagB']); + $subscriberWithAllTags = $this->createSubscriberWithTags('allTags@example.com', ['tagA', 'tagB', 'tagC']); + + $subscribers = (TagSegment::create(['name' => 'testSegment', 'email_list_id' => $this->emailList->id]) + ->syncPositiveTags(['tagA', 'tagC']) + ->getSubscribersQuery() + ->get()); + + $this->assertArrayContainsSubscribers([ + $subscriberWithOneTag, + $subscriberWithManyTags, + $subscriberWithAllTags + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_subscribers_having_all_of_the_given_multiple_tags() + { + Mail::fake(); + + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithTagA = $this->createSubscriberWithTags('tagA@example.com', ['tagA']); + $subscriberWithTagB = $this->createSubscriberWithTags('tagB@example.com', ['tagB']); + $subscriberWithTagAAndB = $this->createSubscriberWithTags('tagAAndB@example.com', ['tagA', 'tagB']); + $subscriberWithAllTags = $this->createSubscriberWithTags('allTags@example.com', ['tagA', 'tagB', 'tagC']); + + $subscribers = (TagSegment::create([ + 'name' => 'testSegment', + 'email_list_id' => $this->emailList->id, + 'all_positive_tags_required' => true, + ]) + ->syncPositiveTags(['tagA', 'tagC']) + ->getSubscribersQuery() + ->get()); + + $this->assertArrayContainsSubscribers([ + $subscriberWithAllTags, + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_subscribers_not_having_a_tag() + { + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithTagA = $this->createSubscriberWithTags('tagA@example.com', ['tagA']); + $subscriberWithTagB = $this->createSubscriberWithTags('tagB@example.com', ['tagB']); + $subscriberWithManyTags = $this->createSubscriberWithTags('tagAandTagB@example.com', ['tagA', 'tagB']); + + $subscribers = (TagSegment::create([ + 'name' => 'testSegment', + 'email_list_id' => $this->emailList->id, + ]) + ->syncNegativeTags(['tagB']) + ->getSubscribersQuery() + ->get()); + + + $this->assertArrayContainsSubscribers([ + $subscriberWithoutTag, + $subscriberWithTagA + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_subscribers_not_having_multiple_tags() + { + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithTagA = $this->createSubscriberWithTags('tagA@example.com', ['tagA']); + $subscriberWithTagB = $this->createSubscriberWithTags('tagB@example.com', ['tagB']); + $subscriberWithManyTags = $this->createSubscriberWithTags('tagAandTagB@example.com', ['tagA', 'tagB']); + + $subscribers = (TagSegment::create([ + 'name' => 'testSegment', + 'email_list_id' => $this->emailList->id, + ]) + ->syncNegativeTags(['tagA', 'tagB']) + ->getSubscribersQuery() + ->get()); + + + $this->assertArrayContainsSubscribers([ + $subscriberWithoutTag, + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_subscribers_not_having_all_given_tags() + { + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithTagA = $this->createSubscriberWithTags('tagA@example.com', ['tagA']); + $subscriberWithTagB = $this->createSubscriberWithTags('tagB@example.com', ['tagB']); + $subscriberWithManyTags = $this->createSubscriberWithTags('tagAandTagB@example.com', ['tagA', 'tagB']); + + $subscribers = (TagSegment::create([ + 'name' => 'testSegment', + 'email_list_id' => $this->emailList->id, + 'all_negative_tags_required' => true + ]) + ->syncNegativeTags(['tagA', 'tagB']) + ->getSubscribersQuery() + ->get()); + + $this->assertArrayContainsSubscribers([ + $subscriberWithoutTag, + $subscriberWithTagA, + $subscriberWithTagB + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_positive_and_negative_segments_in_one_go() + { + $subscriberWithoutTag = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriberWithTagA = $this->createSubscriberWithTags('tagA@example.com', ['tagA']); + $subscriberWithTagB = $this->createSubscriberWithTags('tagB@example.com', ['tagB']); + $subscriberWithManyTags = $this->createSubscriberWithTags('tagAandTagB@example.com', ['tagA', 'tagB', 'tagC']); + + $subscribers = (TagSegment::create([ + 'name' => 'testSegment', + 'email_list_id' => $this->emailList->id, + ]) + ->syncPositiveTags(['tagA', 'tagB']) + ->syncNegativeTags([ 'tagC']) + ->getSubscribersQuery() + ->get()); + + $this->assertArrayContainsSubscribers([ + $subscriberWithTagA, + $subscriberWithTagB + ], $subscribers); + } + + /** @test */ + public function it_can_segment_on_positive_and_negative_segments_all_required_in_one_go() + { + $subscriber1 = $this->createSubscriberWithTags('noTag@example.com', []); + $subscriber2 = $this->createSubscriberWithTags('noTag@example.com', ['tagA']); + $subscriber3 = $this->createSubscriberWithTags('tagA@example.com', ['tagA', 'tagB']); + $subscriber4 = $this->createSubscriberWithTags('tagB@example.com', ['tagA', 'tagB', 'tagC']); + $subscriber5 = $this->createSubscriberWithTags('tagAandTagB@example.com', ['tagA', 'tagB', 'tagC', 'tagD']); + + $subscribers = (TagSegment::create([ + 'name' => 'testSegment', + 'email_list_id' => $this->emailList->id, + 'all_positive_tags_required' => true, + 'all_negative_tags_required' => true, + ]) + ->syncPositiveTags(['tagA', 'tagB']) + ->syncNegativeTags([ 'tagC', 'tagD']) + ->getSubscribersQuery() + ->get()); + + $this->assertArrayContainsSubscribers([ + $subscriber3, + $subscriber4 + ], $subscribers); + } + + protected function createSubscriberWithTags(string $email, array $tags = []): Subscriber + { + /** @var Subscriber $subscriber */ + $subscriber = factory(Subscriber::class)->create([ + 'email' => $email, + 'email_list_id' => $this->emailList->id, + ]); + + $subscriber->addTags($tags); + + return $subscriber->refresh(); + } + + protected function assertArrayContainsSubscribers(array $expectedSubscribers, Collection $actualSubscribers) + { + $expectedSubscribers = collect($expectedSubscribers); + + $expectedSubscribers->each(function (Subscriber $expectedSubscriber) use ($actualSubscribers) { + $this->assertContains( + $expectedSubscriber->id, + $actualSubscribers->pluck('id')->toArray(), + "Expected subscriber {$expectedSubscriber->email} not found in actual subscribers)" + ); + }); + + $actualSubscribers->each(function (Subscriber $actualSubscriber) use ($expectedSubscribers) { + $this->assertContains( + $actualSubscriber->id, + $expectedSubscribers->pluck('id')->toArray(), + "Actual subscriber {$actualSubscriber->email} not found in expected subscribers)" + ); + }); + } +} diff --git a/Models/SendTest.php b/Models/SendTest.php new file mode 100644 index 0000000..a2039c4 --- /dev/null +++ b/Models/SendTest.php @@ -0,0 +1,180 @@ +create([ + 'transport_message_id' => '1234', + ]); + + $this->assertTrue($send->is(Send::findByTransportMessageId('1234'))); + } + + /** @test */ + public function it_will_unsubscribe_when_there_is_a_permanent_bounce() + { + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = factory(Subscriber::class)->create(); + + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = $subscriber->emailList; + + $campaign = factory(Campaign::class)->create([ + 'email_list_id' => $emailList->id, + ]); + + $send = factory(Send::class)->create([ + 'campaign_id' => $campaign->id, + 'subscriber_id' => $subscriber->id, + ]); + + $send->registerBounce(); + + $this->assertDatabaseHas('mailcoach_send_feedback_items', [ + 'send_id' => $send->id, + 'type' => SendFeedbackType::BOUNCE + ]); + + $this->assertFalse($emailList->isSubscribed($subscriber->email)); + } + + /** @test */ + public function it_can_receive_a_complaint() + { + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = factory(Subscriber::class)->create(); + + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = $subscriber->emailList; + + $campaign = factory(Campaign::class)->create([ + 'email_list_id' => $emailList->id, + ]); + + $send = factory(Send::class)->create([ + 'campaign_id' => $campaign->id, + 'subscriber_id' => $subscriber->id, + ]); + + $send->registerComplaint(); + + $this->assertDatabaseHas('mailcoach_send_feedback_items', [ + 'send_id' => $send->id, + 'type' => SendFeedbackType::COMPLAINT + ]); + + $this->assertFalse($emailList->isSubscribed($subscriber->email)); + } + + /** @test */ + public function it_will_not_register_an_open_if_it_was_recently_opened() + { + TestTime::freeze(); + + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create(); + $send->campaign->update(['track_opens' => true]); + + $send->registerOpen(); + $this->assertCount(1, $send->opens()->get()); + + TestTime::addSeconds(4); + $send->registerOpen(); + $this->assertCount(1, $send->opens()->get()); + + TestTime::addSeconds(1); + $send->registerOpen(); + $this->assertCount(2, $send->opens()->get()); + } + + /** @test */ + public function it_will_not_register_a_click_of_an_unsubscribe_link() + { + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create(); + $send->campaign->update(['track_clicks' => true]); + + $unsubscribeUrl = $send->subscriber->unsubscribeUrl($send); + + $send->registerClick($unsubscribeUrl); + + $this->assertCount(0, $send->clicks()->get()); + } + + /** @test */ + public function registering_clicks_will_update_the_click_count() + { + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = factory(Subscriber::class)->create(); + + /** @var \Spatie\Mailcoach\Models\EmailList $emailList */ + $emailList = $subscriber->emailList; + + /** @var \Spatie\Mailcoach\Models\Subscriber $anotherSubscriber */ + $anotherSubscriber = factory(Subscriber::class)->create(['email_list_id' => $emailList->id]); + + /** @var \Spatie\Mailcoach\Models\Campaign $campaign */ + $campaign = factory(Campaign::class)->create([ + 'email_list_id' => $emailList->id, + 'track_clicks' => true, + ]); + + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create([ + 'campaign_id' => $campaign->id, + 'subscriber_id' => $subscriber->id, + ]); + + /** @var \Spatie\Mailcoach\Models\Send $anotherSend */ + $anotherSend = factory(Send::class)->create([ + 'campaign_id' => $campaign->id, + 'subscriber_id' => $anotherSubscriber->id, + ]); + + $linkA = 'https://mailcoach.app'; + $linkB = 'https://spatie.be'; + + $campaignClick = $send->registerClick($linkA); + + /** @var \Spatie\Mailcoach\Models\CampaignLink $campaignLinkA */ + $campaignLinkA = $campaignClick->link; + + $this->assertEquals(1, $campaignLinkA->click_count); + $this->assertEquals(1, $campaignLinkA->unique_click_count); + + $send->registerClick($linkA); + $this->assertEquals(2, $campaignLinkA->refresh()->click_count); + $this->assertEquals(1, $campaignLinkA->refresh()->unique_click_count); + + $anotherSend->registerClick($linkA); + $this->assertEquals(3, $campaignLinkA->refresh()->click_count); + $this->assertEquals(2, $campaignLinkA->refresh()->unique_click_count); + + $anotherSend->registerClick($linkA); + $this->assertEquals(4, $campaignLinkA->refresh()->click_count); + $this->assertEquals(2, $campaignLinkA->refresh()->unique_click_count); + + $campaignClick = $send->registerClick($linkB); + + /** @var \Spatie\Mailcoach\Models\CampaignLink $campaignLinkA */ + $campaignLinkB = $campaignClick->link; + + $this->assertEquals(1, $campaignLinkB->click_count); + $this->assertEquals(1, $campaignLinkB->unique_click_count); + + $send->registerClick($linkB); + $this->assertEquals(2, $campaignLinkB->refresh()->click_count); + $this->assertEquals(1, $campaignLinkB->refresh()->unique_click_count); + } +} diff --git a/Models/SubscriberTest.php b/Models/SubscriberTest.php new file mode 100644 index 0000000..ad18596 --- /dev/null +++ b/Models/SubscriberTest.php @@ -0,0 +1,161 @@ +emailList = factory(EmailList::class)->create(); + + Mail::fake(); + } + + /** @test */ + public function it_will_only_subscribe_a_subscriber_once() + { + $this->assertFalse($this->emailList->isSubscribed('john@example.com')); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + + $this->assertEquals(1, Subscriber::count()); + } + + /** @test */ + public function it_can_resubscribe_someone() + { + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + + $subscriber->unsubscribe(); + $this->assertFalse($this->emailList->isSubscribed('john@example.com')); + + Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + } + + /** @test */ + public function it_will_send_a_confirmation_mail_if_the_list_requires_double_optin() + { + $this->emailList->update([ + 'requires_confirmation' => true, + ]); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertFalse($this->emailList->isSubscribed('john@example.com')); + + Mail::assertQueued(ConfirmSubscriberMail::class, function (ConfirmSubscriberMail $mail) use ($subscriber) { + $this->assertEquals($subscriber->uuid, $mail->subscriber->uuid); + + return true; + }); + } + + /** @test */ + public function it_can_immediately_subscribe_someone_and_not_send_a_mail_even_with_double_opt_in_enabled() + { + $this->emailList->update([ + 'requires_confirmation' => true, + ]); + + $subscriber = Subscriber::createWithEmail('john@example.com') + ->skipConfirmation() + ->subscribeTo($this->emailList); + + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->status); + $this->assertTrue($this->emailList->isSubscribed('john@example.com')); + + Mail::assertNotQueued(ConfirmSubscriberMail::class); + } + + /** @test */ + public function no_email_will_be_sent_when_adding_someone_that_was_already_subscribed() + { + $subscriber = factory(Subscriber::class)->create(); + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->status); + $subscriber->emailList->update(['requires_confirmation' => true]); + + $subscriber = Subscriber::createWithEmail('john@example.com')->subscribeTo($this->emailList); + $this->assertEquals(SubscriptionStatus::SUBSCRIBED, $subscriber->status); + + Mail::assertNothingQueued(); + } + + /** @test */ + public function it_can_get_all_sends() + { + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create(); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = $send->subscriber; + + $sends = $subscriber->sends; + + $this->assertCount(1, $sends); + + $this->assertEquals($send->uuid, $sends->first()->uuid); + } + + /** @test */ + public function it_can_get_all_opens() + { + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create(); + $send->campaign->update(['track_opens' => true]); + + $send->registerOpen(); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = $send->subscriber; + + $opens = $subscriber->opens; + $this->assertCount(1, $opens); + + $this->assertEquals($send->uuid, $subscriber->opens->first()->send->uuid); + $this->assertEquals($subscriber->uuid, $subscriber->opens->first()->subscriber->uuid); + } + + /** @test */ + public function it_can_get_all_clicks() + { + /** @var \Spatie\Mailcoach\Models\Send $send */ + $send = factory(Send::class)->create(); + $send->campaign->update(['track_clicks' => true]); + + $send->registerClick('https://example.com'); + $send->registerClick('https://another-domain.com'); + $send->registerClick('https://example.com'); + + /** @var \Spatie\Mailcoach\Models\Subscriber $subscriber */ + $subscriber = $send->subscriber; + + $clicks = $subscriber->clicks; + $this->assertCount(3, $clicks); + + $uniqueClicks = $subscriber->uniqueClicks; + $this->assertCount(2, $uniqueClicks); + + $this->assertEquals( + ['https://example.com','https://another-domain.com'], + $uniqueClicks->pluck('link.url')->toArray() + ); + } +} diff --git a/Models/TagTest.php b/Models/TagTest.php new file mode 100644 index 0000000..603b09f --- /dev/null +++ b/Models/TagTest.php @@ -0,0 +1,136 @@ +subscriber = factory(Subscriber::class)->create(); + + $this->subscriberOfAnotherEmailList = factory(Subscriber::class)->create(); + } + + /** @test */ + public function a_tag_can_be_added() + { + $this->subscriber->addTag('test1'); + + $this->assertSubscriberHasTags(['test1']); + + $tagOfEmailList = $this->subscriber->emailList->tags()->first(); + + $this->assertEquals('test1', $tagOfEmailList->name); + + $tag = Tag::first(); + + $this->assertEquals($this->subscriber->emailList->id, $tag->emailList->id); + $this->assertEquals($this->subscriber->uuid, $tag->subscribers()->first()->uuid); + } + + /** @test */ + public function multiple_tags_can_be_added_in_one_go() + { + $this->subscriber->addTags(['test1', 'test2']); + + $this->assertSubscriberHasTags(['test1', 'test2']); + } + + /** @test */ + public function it_will_not_save_duplicate_tags() + { + $this->subscriber->addTags(['test1', 'test2']); + $this->subscriber->addTags(['test1', 'test2']); + + $this->assertSubscriberHasTags(['test1', 'test2']); + $this->assertCount(2, Tag::all()); + } + + /** @test */ + public function a_tag_can_be_removed() + { + $this->subscriber + ->addTags(['test1', 'test2']) + ->removeTag('test2'); + + $this->assertSubscriberHasTags(['test1']); + + $this->assertCount(2, Tag::all()); + } + + /** @test */ + public function multiple_tags_can_be_removed_in_one_go() + { + $this->subscriber + ->addTags(['test1', 'test2', 'test3']) + ->removeTags(['test1', 'test3']); + + $this->assertSubscriberHasTags(['test2']); + } + + /** @test */ + public function it_can_determine_if_it_has_a_tag() + { + $this->assertFalse($this->subscriber->hasTag('test2')); + + $this->subscriber->addTag('test'); + $this->assertFalse($this->subscriber->hasTag('test2')); + + $this->subscriber->addTag('test2'); + $this->assertTrue($this->subscriber->hasTag('test2')); + } + + /** @test */ + public function it_can_sync_tags() + { + $this->subscriber->syncTags(['test1', 'test2', 'test3']); + + $this->assertSubscriberHasTags(['test1', 'test2', 'test3']); + + $this->subscriber->syncTags(['test2', 'test4']); + + $this->assertSubscriberHasTags(['test2', 'test4']); + + $this->subscriber->syncTags([]); + $this->assertSubscriberHasTags([]); + } + + /** @test */ + public function tags_are_scoped_per_emailList() + { + $this->subscriber->addTag('test1'); + $this->subscriberOfAnotherEmailList->addTag('test1'); + + $this->assertDatabaseHas('mailcoach_tags', [ + 'email_list_id' => $this->subscriber->id, + 'name' => 'test1', + ]); + + $this->assertDatabaseHas('mailcoach_tags', [ + 'email_list_id' => $this->subscriberOfAnotherEmailList->id, + 'name' => 'test1', + ]); + } + + protected function assertSubscriberHasTags(array $expectedTagNames) + { + $actualTags = $this->subscriber->refresh()->tags()->pluck('name')->toArray(); + $this->assertEquals( + $actualTags, + $expectedTagNames, + 'Subscriber did not have the expected tags. It currently has ' . implode(', ', $actualTags), + ); + } +} diff --git a/Models/__snapshots__/CampaignTest__it_can_inline_the_styles_of_the_html__1.html b/Models/__snapshots__/CampaignTest__it_can_inline_the_styles_of_the_html__1.html new file mode 100644 index 0000000..6f3cb8c --- /dev/null +++ b/Models/__snapshots__/CampaignTest__it_can_inline_the_styles_of_the_html__1.html @@ -0,0 +1,6 @@ + + +

-//W3C//DTDHTML4.0Transitional//EN""http://www.w3.org/TR/REC-html40/loose.dtd">

+ +Mybody + diff --git a/Rules/DateTimeFieldRuleTest.php b/Rules/DateTimeFieldRuleTest.php new file mode 100644 index 0000000..ecdf983 --- /dev/null +++ b/Rules/DateTimeFieldRuleTest.php @@ -0,0 +1,74 @@ +assertTrue( + (new DateTimeFieldRule())->passes('datetime', [ + 'date' => now()->addDay()->format('Y-m-d'), + 'hours' => '12', + 'minutes' => '15' + ]) + ); + } + + /** @test */ + public function it_doesnt_pass_if_the_input_isnt_an_array() + { + $this->assertFalse( + (new DateTimeFieldRule())->passes('datetime', '2020-12-05 12:15') + ); + } + + /** @test */ + public function it_doesnt_pass_if_the_date_is_missing() + { + $this->assertFalse( + (new DateTimeFieldRule())->passes('datetime', [ + 'hours' => '12', + 'minutes' => '15' + ]) + ); + } + + /** @test */ + public function it_doesnt_pass_if_hours_are_missing() + { + $this->assertFalse( + (new DateTimeFieldRule())->passes('datetime', [ + 'date' => now()->addDay()->format('Y-m-d'), + 'minutes' => '15' + ]) + ); + } + + /** @test */ + public function it_doesnt_pass_if_minutes_are_missing() + { + $this->assertFalse( + (new DateTimeFieldRule())->passes('datetime', [ + 'date' => now()->addDay()->format('Y-m-d'), + 'hours' => '12', + ]) + ); + } + + /** @test */ + public function it_doesnt_passes_if_the_date_time_is_in_the_past() + { + $this->assertFalse( + (new DateTimeFieldRule())->passes('datetime', [ + 'date' => now()->subDay()->format('Y-m-d'), + 'hours' => '12', + 'minutes' => '15' + ]) + ); + } +} diff --git a/Rules/EmailRuleSubscriptionTest.php b/Rules/EmailRuleSubscriptionTest.php new file mode 100644 index 0000000..f0512c6 --- /dev/null +++ b/Rules/EmailRuleSubscriptionTest.php @@ -0,0 +1,69 @@ +emailList = factory(EmailList::class)->create(); + + $this->rule = new EmailListSubscriptionRule($this->emailList); + } + + /** @test */ + public function it_will_not_pass_if_the_given_email_is_already_subscribed() + { + $this->assertTrue($this->rule->passes('email', 'john@example.com')); + $this->emailList->subscribe('john@example.com'); + $this->assertFalse($this->rule->passes('email', 'john@example.com')); + + $otherEmailList = factory(EmailList::class)->create(); + $rule = new EmailListSubscriptionRule($otherEmailList); + $this->assertTrue($rule->passes('email', 'john@example.com')); + } + + /** @test */ + public function it_will_pass_for_emails_that_are_still_pending() + { + $this->emailList->update(['requires_confirmation' => true]); + $this->emailList->subscribe('john@example.com'); + $this->assertEquals(SubscriptionStatus::UNCONFIRMED, $this->emailList->getSubscriptionStatus('john@example.com')); + + $this->assertTrue($this->rule->passes('email', 'john@example.com')); + } + + /** @test */ + public function it_will_pass_for_emails_that_are_unsubscribed() + { + $this->emailList->update(['requires_confirmation' => true]); + $this->emailList->subscribe('john@example.com'); + $this->emailList->unsubscribe('john@example.com'); + $this->assertEquals(SubscriptionStatus::UNSUBSCRIBED, $this->emailList->getSubscriptionStatus('john@example.com')); + + $this->assertTrue($this->rule->passes('email', 'john@example.com')); + } + + /** @test */ + public function it_will_allow_to_subscribe_an_email_that_is_already_subscribed_to_another_list() + { + $this->emailList->subscribe('john@example.com'); + + $anotherEmailList = factory(EmailList::class)->create(); + + $this->assertTrue((new EmailListSubscriptionRule($anotherEmailList))->passes('email', 'john@example.com')); + } +} diff --git a/Rules/HtmlRuleTest.php b/Rules/HtmlRuleTest.php new file mode 100644 index 0000000..9f829e9 --- /dev/null +++ b/Rules/HtmlRuleTest.php @@ -0,0 +1,22 @@ +assertTrue($this->rulePasses('Test')); + + $this->assertFalse($this->rulePasses('>>')); + } + + protected function rulePasses(string $html) + { + return (new HtmlRule())->passes('html', $html); + } +} diff --git a/Support/CalculateStatisticsLockTest.php b/Support/CalculateStatisticsLockTest.php new file mode 100644 index 0000000..3435feb --- /dev/null +++ b/Support/CalculateStatisticsLockTest.php @@ -0,0 +1,58 @@ +campaign = factory(Campaign::class)->create(); + + $this->lock = new CalculateStatisticsLock($this->campaign); + + TestTime::freeze(); + } + + /** @test */ + public function it_can_lock_and_release() + { + $this->assertTrue($this->lock->get()); + + $this->assertFalse($this->lock->get()); + + $this->lock->release(); + + $this->assertTrue($this->lock->get()); + } + + /** @test */ + public function it_will_automatically_expire_the_lock_after_10_seconds() + { + $this->assertTrue($this->lock->get()); + + $this->assertFalse($this->lock->get()); + + TestTime::addSeconds(9); + $this->assertFalse($this->lock->get()); + + TestTime::addSecond(); + $this->assertTrue($this->lock->get()); + $this->assertFalse($this->lock->get()); + + TestTime::addSeconds(9); + $this->assertFalse($this->lock->get()); + + TestTime::addSecond(); + $this->assertTrue($this->lock->get()); + } +} diff --git a/Support/ShortNumberTest.php b/Support/ShortNumberTest.php new file mode 100644 index 0000000..ef76af5 --- /dev/null +++ b/Support/ShortNumberTest.php @@ -0,0 +1,26 @@ +assertEquals('1', Str::shortNumber(1)); + $this->assertEquals('100', Str::shortNumber(100)); + $this->assertEquals('500', Str::shortNumber(500)); + $this->assertEquals('999', Str::shortNumber(999)); + $this->assertEquals('1K', Str::shortNumber(1000)); + $this->assertEquals('1.7K', Str::shortNumber(1799)); + + $this->assertEquals('999.9K', Str::shortNumber(999_999)); + $this->assertEquals('1M', Str::shortNumber(1_000_000)); + + $this->assertEquals('🤯', Str::shortNumber(1_000_000_000)); + + } +} diff --git a/Support/VersionTest.php b/Support/VersionTest.php new file mode 100644 index 0000000..037128b --- /dev/null +++ b/Support/VersionTest.php @@ -0,0 +1,38 @@ +version = app(Version::class); + } + + /** @test */ + public function it_can_get_the_current_version() + { + $this->assertIsString($this->version->getCurrentVersion()); + } + + /** @test */ + public function it_can_get_the_latest_version() + { + Cache::clear(); + + $latestVersion = $this->version->getLatestVersionInfo(); + + $this->assertArrayHasKey('version', $latestVersion); + $this->assertArrayHasKey('released_at', $latestVersion); + + $this->assertNotEquals('unknown', $latestVersion['version']); + + } +} diff --git a/TestCase.php b/TestCase.php new file mode 100644 index 0000000..8f51e43 --- /dev/null +++ b/TestCase.php @@ -0,0 +1,93 @@ +withFactories(__DIR__.'/database/factories'); + + Route::mailcoach('mailcoach'); + + $this->withoutExceptionHandling(); + + Redis::flushAll(); + + Gate::define('viewMailcoach', fn () => true); + + TestTime::freeze(); + } + + protected function getPackageProviders($app) + { + return [ + MailcoachServiceProvider::class, + FeedServiceProvider::class, + BladeXServiceProvider::class, + MediaLibraryServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + $app['config']->set('database.default', 'sqlite'); + $app['config']->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + include_once __DIR__.'/../database/migrations/create_mailcoach_tables.php.stub'; + (new CreateMailcoachTables())->up(); + + include_once __DIR__.'/../vendor/spatie/laravel-medialibrary/database/migrations/create_media_table.php.stub'; + (new CreateMediaTable())->up(); + + include_once __DIR__.'/database/migrations/create_users_table.php.stub'; + (new CreateUsersTable())->up(); + } + + protected function simulateUnsubscribes(Collection $sends) + { + $sends->each(function (Send $send) { + $this + ->get(action(UnsubscribeController::class, [$send->subscriber->uuid, $send->uuid])); + }); + } + + public function authenticate() + { + $user = factory(User::class)->create(); + + $this->actingAs($user); + } + + public function assertMatchesHtmlSnapshotWithoutWhitespace(string $content) + { + $contentWithoutWhitespace = preg_replace('/\s/', '', $content); + + $contentWithoutWhitespace = str_replace(PHP_EOL, '', $contentWithoutWhitespace); + + $this->assertMatchesHtmlSnapshot($contentWithoutWhitespace); + } +} diff --git a/TestClasses/CustomConfirmSubscriberAction.php b/TestClasses/CustomConfirmSubscriberAction.php new file mode 100644 index 0000000..920bb0b --- /dev/null +++ b/TestClasses/CustomConfirmSubscriberAction.php @@ -0,0 +1,16 @@ +update(['email' => 'overridden@example.com']); + + parent::execute($subscriber); + } +} diff --git a/TestClasses/CustomConfirmSubscriberMail.php b/TestClasses/CustomConfirmSubscriberMail.php new file mode 100644 index 0000000..33b3a17 --- /dev/null +++ b/TestClasses/CustomConfirmSubscriberMail.php @@ -0,0 +1,9 @@ +email = 'overridden@example.com'; + + return parent::execute($pendingSubscriber); + } +} diff --git a/TestClasses/CustomPersonalizeHtmlAction.php b/TestClasses/CustomPersonalizeHtmlAction.php new file mode 100644 index 0000000..a8b713d --- /dev/null +++ b/TestClasses/CustomPersonalizeHtmlAction.php @@ -0,0 +1,18 @@ +subscriber->update([ + 'email' => 'overridden@example.com', + ]); + + return parent::execute($html, $pendingSend); + } +} diff --git a/TestClasses/CustomPrepareEmailHtmlAction.php b/TestClasses/CustomPrepareEmailHtmlAction.php new file mode 100644 index 0000000..0983d42 --- /dev/null +++ b/TestClasses/CustomPrepareEmailHtmlAction.php @@ -0,0 +1,16 @@ +emailList->subscribers->first()->update(['email' => 'overridden@example.com']); + + parent::execute($campaign); + } +} diff --git a/TestClasses/CustomPrepareWebviewHtmlAction.php b/TestClasses/CustomPrepareWebviewHtmlAction.php new file mode 100644 index 0000000..3d70b85 --- /dev/null +++ b/TestClasses/CustomPrepareWebviewHtmlAction.php @@ -0,0 +1,16 @@ +emailList->subscribers->first()->update(['email' => 'overridden@example.com']); + + parent::execute($campaign); + } +} diff --git a/TestClasses/CustomWelcomeMail.php b/TestClasses/CustomWelcomeMail.php new file mode 100644 index 0000000..d142064 --- /dev/null +++ b/TestClasses/CustomWelcomeMail.php @@ -0,0 +1,9 @@ +subscriber->email === 'john@example.com') { + throw new Exception('Could not personalize html'); + } + + return parent::execute($html, $pendingSend); + } +} diff --git a/TestClasses/TestCampaignMail.php b/TestClasses/TestCampaignMail.php new file mode 100644 index 0000000..02412af --- /dev/null +++ b/TestClasses/TestCampaignMail.php @@ -0,0 +1,9 @@ +email === 'john@example.com'; + } + + public function description(): string + { + return 'only to john'; + } +} diff --git a/TestClasses/TestSegmentAllSubscribers.php b/TestClasses/TestSegmentAllSubscribers.php new file mode 100644 index 0000000..601f35d --- /dev/null +++ b/TestClasses/TestSegmentAllSubscribers.php @@ -0,0 +1,13 @@ +where('email', 'john@example.com'); + } + + public function description(): string + { + return 'only john'; + } +} diff --git a/database/factories/CampaignFactory.php b/database/factories/CampaignFactory.php new file mode 100644 index 0000000..e2cd96d --- /dev/null +++ b/database/factories/CampaignFactory.php @@ -0,0 +1,21 @@ +define(Campaign::class, fn (Generator $faker) => [ + 'subject' => $faker->sentence, + 'from_email' => $faker->email, + 'from_name' => $faker->name, + 'html' => $faker->randomHtml(), + 'track_opens' => $faker->boolean, + 'track_clicks' => $faker->boolean, + 'status' => CampaignStatus::DRAFT, + 'uuid' => $faker->uuid, + 'last_modified_at' => now(), + 'email_list_id' => fn () => factory(EmailList::class)->create(['uuid' => (string)Str::uuid()]) +]); diff --git a/database/factories/CampaignLinkFactory.php b/database/factories/CampaignLinkFactory.php new file mode 100644 index 0000000..5655dd2 --- /dev/null +++ b/database/factories/CampaignLinkFactory.php @@ -0,0 +1,13 @@ +define(CampaignLink::class, fn (Generator $faker) => [ + 'campaign_id' => factory(Campaign::class), + 'link' => $faker->url, +]); diff --git a/database/factories/CampaignOpenFactory.php b/database/factories/CampaignOpenFactory.php new file mode 100644 index 0000000..e690bdd --- /dev/null +++ b/database/factories/CampaignOpenFactory.php @@ -0,0 +1,16 @@ +define(CampaignOpen::class, fn (Generator $faker) => [ + 'send_id' => factory(Send::class), + 'campaign_id' => factory(Campaign::class), + 'subscriber_id' => factory(Subscriber::class), +]); diff --git a/database/factories/CampaignSendBounceFactory.php b/database/factories/CampaignSendBounceFactory.php new file mode 100644 index 0000000..a60c329 --- /dev/null +++ b/database/factories/CampaignSendBounceFactory.php @@ -0,0 +1,11 @@ +define(SendBounce::class, fn (Generator $faker) => [ + 'send_id' => factory(Send::class), + 'severity' => 'permanent', +]); diff --git a/database/factories/CampaignSendFactory.php b/database/factories/CampaignSendFactory.php new file mode 100644 index 0000000..231ab9a --- /dev/null +++ b/database/factories/CampaignSendFactory.php @@ -0,0 +1,13 @@ +define(Send::class, fn (Generator $faker) => [ + 'uuid' => $faker->uuid, + 'campaign_id' => factory(Campaign::class), + 'subscriber_id' => factory(Subscriber::class), +]); diff --git a/database/factories/EmailListFactory.php b/database/factories/EmailListFactory.php new file mode 100644 index 0000000..fd90c09 --- /dev/null +++ b/database/factories/EmailListFactory.php @@ -0,0 +1,12 @@ +define(EmailList::class, fn (Generator $faker) => [ + 'name' => $faker->word, + 'uuid' => $faker->uuid, + 'default_from_email' => $faker->email, + 'default_from_name' => $faker->name, +]); diff --git a/database/factories/SubscriberFactory.php b/database/factories/SubscriberFactory.php new file mode 100644 index 0000000..d245ed9 --- /dev/null +++ b/database/factories/SubscriberFactory.php @@ -0,0 +1,13 @@ +define(Subscriber::class, fn (Generator $faker) => [ + 'email' => $faker->email, + 'uuid' => $faker->uuid, + 'subscribed_at' => now(), + 'email_list_id' => factory(EmailList::class), +]); diff --git a/database/factories/SubscriberImportFactory.php b/database/factories/SubscriberImportFactory.php new file mode 100644 index 0000000..b960f71 --- /dev/null +++ b/database/factories/SubscriberImportFactory.php @@ -0,0 +1,15 @@ +define(SubscriberImport::class, fn (Generator $faker) => [ + 'status' => SubscriberImportStatus::COMPLETED, + 'email_list_id' => factory(EmailList::class), + 'imported_subscribers_count' => $faker->numberBetween(1, 1000), + 'error_count' => $faker->numberBetween(1, 1000), + +]); diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php new file mode 100644 index 0000000..fa28aed --- /dev/null +++ b/database/factories/TagFactory.php @@ -0,0 +1,9 @@ +define(Tag::class, fn (Generator $faker) => [ + 'name' => $faker->word, +]); diff --git a/database/factories/TemplateFactory.php b/database/factories/TemplateFactory.php new file mode 100644 index 0000000..ec5b946 --- /dev/null +++ b/database/factories/TemplateFactory.php @@ -0,0 +1,10 @@ +define(Template::class, fn (Generator $faker) => [ + 'name' => $faker->word, + 'html' => $faker->randomHtml() +]); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..71dd401 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,9 @@ +define(User::class, fn (Generator $faker) => [ + 'email' => $faker->email, +]); diff --git a/database/migrations/create_users_table.php.stub b/database/migrations/create_users_table.php.stub new file mode 100644 index 0000000..0ff0dc8 --- /dev/null +++ b/database/migrations/create_users_table.php.stub @@ -0,0 +1,17 @@ +bigIncrements('id'); + $table->string('email'); + $table->timestamps(); + }); + } +}