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 @@
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); + } }