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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View email in browser
+
+
+
+
+
+
+
+
+ FREEK.DEV
+
+ Hi, welcome to the 94th freek.dev newsletter!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Build your own React
+
+ In a very cool post, Rodrigo Pombo explains the internals of React by rewriting it's core from scratch.
+
+
+
+
+
+
+
+
+
+
+
+
+ Closing Modals with the Back Button in a Vue SPA
+
+ Jess Archer recently gave an excellent talk at Laracon AU. In a new blogpost she explains one one tips given during her talk: how to close modals in a Vue app by using the back button.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Improving Artisan commands
+
+ In this small blog post, I'd like to give you a couple of tips to make your Artisan commands better.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Crafting maintainable Laravel applications
+
+ At Laracon AU, Jason McCreary gave an excellen talk on how to create maintainable Laravel apps. On his blog he published a written down version of the talk.
+
+
+
+
+
+
+
+
+
+
+
+
+ Meanwhile on Twitter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ From the archives
+
+
+
+
+
+
+
+
+
+
+ Advertisement opportunities at freek.dev/advertising .
+
+ You are receiving this mail because you've subscribed at freek.dev . Opt out any time. Unsubscribe .
+
+
+
+
+
+
+
+
+
+
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' => 'Spatie Flare Docs ',
+ '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();
+ });
+ }
+}