Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/code-style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ jobs:
run: composer install --prefer-dist --no-interaction

- name: Check code style (Pint)
run: |
composer require laravel/pint --dev --no-interaction
vendor/bin/pint --test
# The locked Pint, not a freshly required one: `laravel/pint` is
# already a dev dependency, and re-requiring it floats CI onto a
# version nobody runs locally. `--test` so this reports rather than
# silently reformats, which a plain `pint` would do and then pass.
run: vendor/bin/pint --test

static-analysis:
runs-on: ubuntu-latest
Expand All @@ -52,6 +54,9 @@ jobs:
run: composer install --prefer-dist --no-interaction

- name: Run PHPStan
run: |
composer require phpstan/phpstan --dev --no-interaction
vendor/bin/phpstan analyse src --level=5
# `composer analyse`, so CI runs exactly what a developer runs. The
# previous form installed phpstan separately — larastan already
# requires it — and passed `src --level=5` on the command line, which
# overrides both the paths and the level in phpstan.neon, so CI was
# analysing less code at a lower level than anyone local.
run: composer analyse
11 changes: 11 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@ parameters:
- src
level: 7
tmpDir: /tmp/phpstan

ignoreErrors:
# The notifications render this package's own views, named through the
# `waitlist::` namespace that `loadViewsFrom` registers at runtime.
# Analysed on its own there is no application container to register it,
# so the names cannot be resolved and every `markdown()` call reads as
# an unverifiable view. `NotificationMailTest` renders both, which is
# what actually proves they exist.
- identifier: argument.type
message: '#markdown\(\) expects view-string#'
path: src/Notifications/*.php
42 changes: 42 additions & 0 deletions resources/lang/en/notifications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

return [
/*
|--------------------------------------------------------------------------
| Verify Waitlist Email
|--------------------------------------------------------------------------
|
| Sent when somebody signs up, to confirm they can read the address they
| gave. Publish with `--tag=waitlist-lang` to change the words, and
| `--tag=waitlist-views` to change the shape.
|
*/

'verify' => [
'subject' => 'Verify Your Email Address',
'greeting' => 'Hello :name!',
'line' => 'Please verify your email address to confirm your spot on the waitlist.',
'action_text' => 'Verify Email',
'footer' => 'If you did not sign up for this waitlist, you can ignore this email.',
],

/*
|--------------------------------------------------------------------------
| Waitlist Invited
|--------------------------------------------------------------------------
|
| Sent when somebody is let off the waitlist and into the application.
|
*/

'invited' => [
'subject' => "You're Invited!",
'greeting' => 'Hello :name!',
'line' => 'Great news! You have been invited from our waitlist.',
'access_line' => 'You can now access our application and start using all the features.',
'action_text' => 'Get Started',
'footer' => 'Thank you for your patience!',
],
];
14 changes: 14 additions & 0 deletions resources/views/mail/invited.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{{-- Sent when somebody is let off the waitlist. --}}
<x-mail::message>
# {{ __('waitlist::notifications.invited.greeting', ['name' => $name]) }}

{{ __('waitlist::notifications.invited.line') }}

{{ __('waitlist::notifications.invited.access_line') }}

<x-mail::button :url="$url">
{{ __('waitlist::notifications.invited.action_text') }}
</x-mail::button>

{{ __('waitlist::notifications.invited.footer') }}
</x-mail::message>
18 changes: 18 additions & 0 deletions resources/views/mail/verify.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{{--
The waitlist verification email.

Publish with `php artisan vendor:publish --tag=waitlist-views` to change the
layout; the wording lives in the translations, publishable separately with
`--tag=waitlist-lang`.
--}}
<x-mail::message>
# {{ __('waitlist::notifications.verify.greeting', ['name' => $name]) }}

{{ __('waitlist::notifications.verify.line') }}

<x-mail::button :url="$url">
{{ __('waitlist::notifications.verify.action_text') }}
</x-mail::button>

{{ __('waitlist::notifications.verify.footer') }}
</x-mail::message>
13 changes: 6 additions & 7 deletions src/Notifications/VerifyWaitlistEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,12 @@ public function via(object $notifiable): array

public function toMail(object $notifiable): MailMessage
{
$url = route('waitlist.verify', ['token' => $this->entry->verification_token]);

return (new MailMessage)
->subject('Verify Your Email Address')
->greeting("Hello {$this->entry->name}!")
->line('Please verify your email address to confirm your spot on the waitlist.')
->action('Verify Email', $url)
->line('If you did not sign up for this waitlist, you can ignore this email.');
->subject(__('waitlist::notifications.verify.subject'))
->markdown('waitlist::mail.verify', [
'name' => $this->entry->name,
'url' => route('waitlist.verify', ['token' => $this->entry->verification_token]),
'entry' => $this->entry,
]);
}
}
12 changes: 6 additions & 6 deletions src/Notifications/WaitlistInvited.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ public function via(object $notifiable): array
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('You\'re Invited!')
->greeting("Hello {$this->entry->name}!")
->line('Great news! You have been invited from our waitlist.')
->line('You can now access our application and start using all the features.')
->action('Get Started', url('/'))
->line('Thank you for your patience!');
->subject(__('waitlist::notifications.invited.subject'))
->markdown('waitlist::mail.invited', [
'name' => $this->entry->name,
'url' => url('/'),
'entry' => $this->entry,
]);
}
}
21 changes: 21 additions & 0 deletions src/WaitlistServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ public function register(): void

public function boot(): void
{
/*
* Outside the console guard below: an application needs these to
* render mail on a web request, not only when publishing.
*/
$this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'waitlist');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'waitlist');

$this->registerRoutes();
$this->registerListeners();

Expand All @@ -52,6 +59,20 @@ public function boot(): void
$this->publishesMigrations([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'waitlist-migrations');

/*
* The two emails a waitlist signup actually receives. Their words
* used to be hardcoded in the notifications, where nothing
* downstream could reach them — and both classes are `final`, so
* an application could not even subclass its way out.
*/
$this->publishes([
__DIR__.'/../resources/lang' => $this->app->langPath('vendor/waitlist'),
], 'waitlist-lang');

$this->publishes([
__DIR__.'/../resources/views' => $this->app->resourcePath('views/vendor/waitlist'),
], 'waitlist-views');
}
}

Expand Down
66 changes: 66 additions & 0 deletions tests/Feature/NotificationMailTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Notification;
use Illuminate\Support\ServiceProvider;
use OffloadProject\Waitlist\Facades\Waitlist;
use OffloadProject\Waitlist\Notifications\VerifyWaitlistEmail;
use OffloadProject\Waitlist\Notifications\WaitlistInvited;
use OffloadProject\Waitlist\WaitlistServiceProvider;

/*
* Both emails render views now, and their words live in translations rather
* than inside the notifications — which matters more here than it might
* elsewhere, because both classes are `final`. An application could not
* subclass its way to a different greeting; the only route was replacing the
* class through config.
*
* Rendered rather than faked: the rest of the suite asserts a notification was
* sent, which passes just as happily when the view behind it is missing or
* names an undefined variable.
*/

beforeEach(function (): void {
Notification::fake();
Waitlist::create('Beta Program', 'beta');
});

it('renders the verification email', function (): void {
// The token only exists when verification is on, and the link needs it.
config(['waitlist.verification.enabled' => true]);

$entry = Waitlist::for('beta')->add('Ada Lovelace', 'ada@example.com');

$mail = (new VerifyWaitlistEmail($entry))->toMail($entry);
$html = (string) $mail->render();

expect($mail->subject)->toBe(__('waitlist::notifications.verify.subject'))
// The name is interpolated, and the token has to survive into the link.
->and($html)->toContain('Ada Lovelace')
->and($html)->toContain($entry->verification_token)
->and($html)->toContain(__('waitlist::notifications.verify.action_text'))
->and($html)->not->toContain('waitlist::');
});

it('renders the invitation email', function (): void {
$entry = Waitlist::for('beta')->add('Grace Hopper', 'grace@example.com');

$mail = (new WaitlistInvited($entry))->toMail($entry);
$html = (string) $mail->render();

expect($mail->subject)->toBe(__('waitlist::notifications.invited.subject'))
->and($html)->toContain('Grace Hopper')
->and($html)->toContain(__('waitlist::notifications.invited.action_text'))
->and($html)->not->toContain('waitlist::');
});

/*
* The point of the exercise: an application can take both over.
*/
it('offers the views and the copy for publishing', function (): void {
expect(ServiceProvider::pathsToPublish(WaitlistServiceProvider::class, 'waitlist-views'))->not->toBeEmpty()
->and(ServiceProvider::pathsToPublish(WaitlistServiceProvider::class, 'waitlist-lang'))->not->toBeEmpty()
->and(view()->exists('waitlist::mail.verify'))->toBeTrue()
->and(view()->exists('waitlist::mail.invited'))->toBeTrue();
});
Loading