diff --git a/app/Actions/Newsletters/CreateNewsletter.php b/app/Actions/Newsletters/CreateNewsletter.php new file mode 100644 index 0000000..41ad647 --- /dev/null +++ b/app/Actions/Newsletters/CreateNewsletter.php @@ -0,0 +1,37 @@ +handle()->where('publish_date', '>', now()->subWeek())->get(); + + if ($postsFromLastWeek->isEmpty()) { + return null; + } + + $campaign = Campaign::query()->create([ + 'from_email' => $this->fromEmail, + 'subject' => 'Pest Newsletter', + 'html' => view('newsletter.content', ['posts' => $postsFromLastWeek])->render(), + 'email_list_id' => $this->emailList->getKey(), + ]); + + return $campaign; + } +} diff --git a/app/Actions/Newsletters/SendNewsletter.php b/app/Actions/Newsletters/SendNewsletter.php new file mode 100644 index 0000000..79c1050 --- /dev/null +++ b/app/Actions/Newsletters/SendNewsletter.php @@ -0,0 +1,16 @@ +send(); + } +} diff --git a/app/Actions/Newsletters/SendTestNewsletter.php b/app/Actions/Newsletters/SendTestNewsletter.php new file mode 100644 index 0000000..c0d614e --- /dev/null +++ b/app/Actions/Newsletters/SendTestNewsletter.php @@ -0,0 +1,17 @@ +sendTestMail(WinkAuthor::query()->pluck('email')->all()); + } +} diff --git a/app/Console/Commands/BuildNewsletterCommand.php b/app/Console/Commands/BuildNewsletterCommand.php new file mode 100644 index 0000000..fea002d --- /dev/null +++ b/app/Console/Commands/BuildNewsletterCommand.php @@ -0,0 +1,37 @@ +handle()) { + $this->warn('There was no news to send!'); + + return Command::SUCCESS; + } + + $this->info("View the newsletter in your browser: {$campaign->webviewUrl()}"); + $tester->handle($campaign); + + if ($this->option('force') || $this->confirm('Are you sure you want to send this newsletter?')) { + $delay = now()->addHours(3); + SendNewsletter::dispatch($campaign)->delay($delay); + $this->info("The newsletter will be sent to all subscribers in {$delay->diffForHumans()}."); + } + + return Command::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9afa2ee..cdfaab9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -4,6 +4,7 @@ namespace App\Console; +use App\Console\Commands\BuildNewsletterCommand; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Spatie\Health\Commands\RunHealthChecksCommand; @@ -17,6 +18,8 @@ final class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule) { + $schedule->command(BuildNewsletterCommand::class, ['--force' => true])->fridays()->at('17:00'); + $schedule->command('mailcoach:calculate-statistics')->everyMinute(); $schedule->command('mailcoach:send-scheduled-campaigns')->everyMinute(); $schedule->command('mailcoach:send-campaign-summary-mail')->hourly(); diff --git a/app/Contracts/Actions/Newsletters/CreatesNewsletter.php b/app/Contracts/Actions/Newsletters/CreatesNewsletter.php new file mode 100644 index 0000000..2f800d6 --- /dev/null +++ b/app/Contracts/Actions/Newsletters/CreatesNewsletter.php @@ -0,0 +1,12 @@ +handle($this->campaign); + } +} diff --git a/app/Providers/ActionServiceProvider.php b/app/Providers/ActionServiceProvider.php index 31a85e8..84be903 100644 --- a/app/Providers/ActionServiceProvider.php +++ b/app/Providers/ActionServiceProvider.php @@ -4,13 +4,21 @@ namespace App\Providers; +use App\Actions\Newsletters\CreateNewsletter; +use App\Actions\Newsletters\SendNewsletter; +use App\Actions\Newsletters\SendTestNewsletter; use App\Actions\Resources\ProvidePostResource; use App\Actions\Subscriptions\CreateSubscription; use App\Actions\Subscriptions\DeleteSubscription; +use App\Contracts\Actions\Newsletters\CreatesNewsletter; +use App\Contracts\Actions\Newsletters\SendsNewsletter; +use App\Contracts\Actions\Newsletters\SendsTestNewsletter; use App\Contracts\Actions\Resources\ProvidesPostResource; use App\Contracts\Actions\Subscriptions\CreatesSubscription; use App\Contracts\Actions\Subscriptions\DeletesSubscription; +use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; +use Spatie\Mailcoach\Domain\Audience\Models\EmailList; final class ActionServiceProvider extends ServiceProvider { @@ -21,5 +29,18 @@ final class ActionServiceProvider extends ServiceProvider ProvidesPostResource::class => ProvidePostResource::class, CreatesSubscription::class => CreateSubscription::class, DeletesSubscription::class => DeleteSubscription::class, + CreatesNewsletter::class => CreateNewsletter::class, + SendsTestNewsletter::class => SendTestNewsletter::class, + SendsNewsletter::class => SendNewsletter::class, ]; + + public function register(): void + { + $this->app->bind(CreateNewsletter::class, function (Application $app) { + return new CreateNewsletter( + $app->make(EmailList::class), + $app->make('config')->get('mail.from.address'), + ); + }); + } } diff --git a/composer.lock b/composer.lock index f34cd4e..045598e 100644 --- a/composer.lock +++ b/composer.lock @@ -12215,16 +12215,16 @@ }, { "name": "spatie/laravel-ray", - "version": "1.26.4", + "version": "1.26.5", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "eebd571834d3940b1d8314950198b4ae71789a82" + "reference": "857d8fe465dbc025cbeb2735fe690ff9496f5f98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/eebd571834d3940b1d8314950198b4ae71789a82", - "reference": "eebd571834d3940b1d8314950198b4ae71789a82", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/857d8fe465dbc025cbeb2735fe690ff9496f5f98", + "reference": "857d8fe465dbc025cbeb2735fe690ff9496f5f98", "shasum": "" }, "require": { @@ -12236,7 +12236,7 @@ "php": "^7.3|^8.0", "spatie/backtrace": "^1.0", "spatie/ray": "^1.27.1", - "symfony/stopwatch": "4.2|^5.1", + "symfony/stopwatch": "4.2|^5.1|^6.0", "zbateson/mail-mime-parser": "^1.3.1|^2.0" }, "require-dev": { @@ -12281,7 +12281,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.26.4" + "source": "https://github.com/spatie/laravel-ray/tree/1.26.5" }, "funding": [ { @@ -12293,7 +12293,7 @@ "type": "other" } ], - "time": "2021-12-10T12:10:14+00:00" + "time": "2021-12-21T13:51:47+00:00" }, { "name": "spatie/macroable", diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 339df6a..4068807 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,18 +13,19 @@ public function run(Application $app): void { WinkAuthor::query()->delete(); - if ($app->environment('local')) { - AuthorFactory::new()->create(['email' => 'luke@downing.tech']); - } - // @phpstan-ignore-next-line $this->resolve(EmailListSeeder::class)->run(); // @phpstan-ignore-next-line $this->resolve(TagSeeder::class)->run(); - if (app()->environment('local')) { + if ($app->environment('local')) { + $author = AuthorFactory::new()->create([ + 'email' => 'luke@downing.tech', + 'name' => 'Luke Downing', + ]); + // @phpstan-ignore-next-line - $this->resolve(PostSeeder::class)->run(); + $this->resolve(PostSeeder::class)->forAuthor($author)->run(); } } } diff --git a/database/seeders/PostSeeder.php b/database/seeders/PostSeeder.php index 116be99..a82eddb 100644 --- a/database/seeders/PostSeeder.php +++ b/database/seeders/PostSeeder.php @@ -2,18 +2,32 @@ namespace Database\Seeders; +use Database\Factories\AuthorFactory; use Database\Factories\PostFactory; use Illuminate\Database\Seeder; use Illuminate\Support\Str; +use Wink\WinkAuthor; use Wink\WinkTag; class PostSeeder extends Seeder { + private ?WinkAuthor $author = null; + + public function forAuthor(WinkAuthor $author): self + { + $this->author = $author; + + return $this; + } + public function run(): void { - PostFactory::new()->count(10)->create(); - PostFactory::new()->count(30)->hasTags($this->tag('blog'))->create(); - PostFactory::new()->count(5)->hasTags($this->tag('blog'))->unpublished()->create(); + /** @var WinkAuthor $author */ + $author = $this->author ?? AuthorFactory::new()->create(); + + PostFactory::new()->count(10)->for($author, 'author')->create(); + PostFactory::new()->count(30)->for($author, 'author')->hasTags($this->tag('blog'))->create(); + PostFactory::new()->count(5)->for($author, 'author')->hasTags($this->tag('blog'))->unpublished()->create(); } private function tag(string $slug): WinkTag diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d50d11a..76a606b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,8 +21,8 @@ - - + + diff --git a/resources/views/newsletter/content.blade.php b/resources/views/newsletter/content.blade.php new file mode 100644 index 0000000..52cd13d --- /dev/null +++ b/resources/views/newsletter/content.blade.php @@ -0,0 +1,13 @@ +

Hey there!

+

Here's what's new with Pest this week:

+ +

Thanks for supporting Pest PHP. Keep testing!

+Regards, The Pest Team diff --git a/tests/Feature/Actions/Newsletters/CreateNewsletterTest.php b/tests/Feature/Actions/Newsletters/CreateNewsletterTest.php new file mode 100644 index 0000000..91352eb --- /dev/null +++ b/tests/Feature/Actions/Newsletters/CreateNewsletterTest.php @@ -0,0 +1,47 @@ +app->make(CreatesNewsletter::class))->toBeInstanceOf(CreateNewsletter::class); +}); + +it('creates a newsletter', function () { + expect(Campaign::count())->toBe(0); + + post()->count(5)->create(); + + $action = $this->app->make(CreatesNewsletter::class); + $campaign = $action->handle(); + + expect(Campaign::count())->toBe(1); + expect($campaign->subject)->toBe('Pest Newsletter'); +}); + +it('will not create a newsletter if no posts have been published in the last week', function () { + // This post is too old + post()->create(['publish_date' => now()->subWeek()->subDay()]); + // This post is for the next newsletter + post()->create(['publish_date' => now()->addDay()]); + + $action = $this->app->make(CreatesNewsletter::class); + $action->handle(); + + expect(Campaign::count())->toBe(0); +}); + +it('will contain the titles and links to all posts published in the last week', function () { + $posts = post()->count(5)->create(); + + $action = $this->app->make(CreatesNewsletter::class); + $content = $action->handle()->getHtml(); + + $posts->each(fn (WinkPost $post) => expect($content) + ->toContain($post->title) + ->toContain(route('posts.show', $post->slug))); +}); diff --git a/tests/Feature/Actions/Newsletters/SendNewsletterTest.php b/tests/Feature/Actions/Newsletters/SendNewsletterTest.php new file mode 100644 index 0000000..a11c295 --- /dev/null +++ b/tests/Feature/Actions/Newsletters/SendNewsletterTest.php @@ -0,0 +1,25 @@ +app->make(SendsNewsletter::class)) + ->toBeInstanceOf(SendNewsletter::class); +}); + +it('sends a newsletter', function () { + Queue::fake(); + + $campaign = CampaignFactory::new()->create(); + + $action = $this->app->make(SendNewsletter::class); + $action->handle($campaign); + + Queue::assertPushed(SendCampaignJob::class); +}); diff --git a/tests/Feature/Actions/Newsletters/SendTestNewsletterTest.php b/tests/Feature/Actions/Newsletters/SendTestNewsletterTest.php new file mode 100644 index 0000000..901a2a4 --- /dev/null +++ b/tests/Feature/Actions/Newsletters/SendTestNewsletterTest.php @@ -0,0 +1,27 @@ +app->make(SendsTestNewsletter::class)) + ->toBeInstanceOf(SendTestNewsletter::class); +}); + +it('sends all authors a test newsletter', function () { + Queue::fake(); + + $authors = author()->count(3)->create(); + $campaign = CampaignFactory::new()->create(); + + $action = $this->app->make(SendTestNewsletter::class); + $action->handle($campaign); + + $authors->each(fn (WinkAuthor $author) => Queue::assertPushed(fn (SendCampaignTestJob $job) => $job->email === $author->email)); +}); diff --git a/tests/Feature/Console/Commands/BuildNewsletterTest.php b/tests/Feature/Console/Commands/BuildNewsletterTest.php new file mode 100644 index 0000000..fa7b985 --- /dev/null +++ b/tests/Feature/Console/Commands/BuildNewsletterTest.php @@ -0,0 +1,54 @@ +startOfDay()); + Queue::fake(); + + // A post published this week will get things working + post()->create(); + $campaign = CampaignFactory::new()->create(); + + $this->expectToUseAction(CreatesNewsletter::class)->andReturn($campaign); + $this->expectToUseAction(SendsTestNewsletter::class); + + $this->artisan('site:newsletter', ['--force' => true]); + + Queue::assertPushed(fn (SendNewsletter $job) => now()->addHours(3)->equalTo($job->delay)); +}); + +it('will not test or send the campaign if there is no campaign to send', function () { + Queue::fake(); + + $this->expectToUseAction(CreatesNewsletter::class)->andReturnNull(); + $this->expectNotToUseAction(SendsTestNewsletter::class); + + $this->artisan('site:newsletter', ['--force' => true]) + ->expectsOutput('There was no news to send!'); + + Queue::assertNotPushed(SendNewsletter::class); +}); + +it('will confirm sending if --force is not set', function () { + post()->create(); + + $this->artisan('site:newsletter') + ->expectsConfirmation('Are you sure you want to send this newsletter?'); +}); + +it('will output the web view for the campaign', function () { + $campaign = CampaignFactory::new()->create(); + + $this->expectToUseAction(CreatesNewsletter::class)->andReturn($campaign); + + $this->artisan('site:newsletter', ['--force' => true]) + ->expectsOutput("View the newsletter in your browser: {$campaign->webviewUrl()}"); +}); diff --git a/tests/Feature/Jobs/SendNewsletterTest.php b/tests/Feature/Jobs/SendNewsletterTest.php new file mode 100644 index 0000000..ee8e17a --- /dev/null +++ b/tests/Feature/Jobs/SendNewsletterTest.php @@ -0,0 +1,16 @@ +create(); + + $this->expectToUseAction(SendsNewsletter::class) + ->withArgs(fn ($givenCampaign) => $givenCampaign->is($campaign)); + + SendNewsletter::dispatchSync($campaign); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 117e6c9..90a3e92 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -29,4 +29,9 @@ protected function expectToUseAction(string $action, string $method = 'handle') { return $this->spy($action)->shouldReceive($method)->atLeast()->once(); } + + protected function expectNotToUseAction(string $action, string $method = 'handle') + { + return $this->spy($action)->shouldNotReceive($method); + } }