diff --git a/app/Events/ReplyWasCreated.php b/app/Events/ReplyWasCreated.php new file mode 100644 index 000000000..c091f63b6 --- /dev/null +++ b/app/Events/ReplyWasCreated.php @@ -0,0 +1,21 @@ +reply = $reply; + } +} diff --git a/app/Exceptions/CannotCreateUser.php b/app/Exceptions/CannotCreateUser.php index c97f5486a..6d352c3e7 100644 --- a/app/Exceptions/CannotCreateUser.php +++ b/app/Exceptions/CannotCreateUser.php @@ -4,7 +4,7 @@ use Exception; -class CannotCreateUser extends Exception +final class CannotCreateUser extends Exception { public static function duplicateEmailAddress(string $emailAddress): self { diff --git a/app/Exceptions/CouldNotMarkReplyAsSolution.php b/app/Exceptions/CouldNotMarkReplyAsSolution.php index 7d0ded4f8..0def890f5 100644 --- a/app/Exceptions/CouldNotMarkReplyAsSolution.php +++ b/app/Exceptions/CouldNotMarkReplyAsSolution.php @@ -5,7 +5,7 @@ use Exception; use App\Models\Reply; -class CouldNotMarkReplyAsSolution extends Exception +final class CouldNotMarkReplyAsSolution extends Exception { public static function replyAbleIsNotAThread(Reply $reply): self { diff --git a/app/Helpers/HasUuid.php b/app/Helpers/HasUuid.php new file mode 100644 index 000000000..5d8e44cb6 --- /dev/null +++ b/app/Helpers/HasUuid.php @@ -0,0 +1,29 @@ +uuid); + } + + public function getKeyName() + { + return 'uuid'; + } + + public function getIncrementing() + { + return false; + } + + public static function findByUuidOrFail(UuidInterface $uuid): self + { + return static::where('uuid', $uuid->toString())->firstOrFail(); + } +} diff --git a/app/Helpers/ProvidesSubscriptions.php b/app/Helpers/ProvidesSubscriptions.php new file mode 100644 index 000000000..911efcecd --- /dev/null +++ b/app/Helpers/ProvidesSubscriptions.php @@ -0,0 +1,30 @@ +subscriptionsRelation; + } + + public function subscriptionsRelation(): MorphMany + { + return $this->morphMany(Subscription::class, 'subscriptionable'); + } + + public function hasSubscriber(User $user): bool + { + return $this->subscriptionsRelation() + ->where('user_id', $user->id()) + ->exists(); + } +} diff --git a/app/Http/Controllers/Auth/GithubController.php b/app/Http/Controllers/Auth/GithubController.php index 3d71ebe4f..f455caa46 100644 --- a/app/Http/Controllers/Auth/GithubController.php +++ b/app/Http/Controllers/Auth/GithubController.php @@ -29,7 +29,7 @@ public function redirectToProvider() public function handleProviderCallback() { try { - $socialiteUser = Socialite::driver('github')->user(); + $socialiteUser = $this->getSocialiteUser(); } catch (InvalidStateException $exception) { $this->error('errors.github_invalid_state'); @@ -38,16 +38,21 @@ public function handleProviderCallback() try { $user = User::findByGithubId($socialiteUser->getId()); - - return $this->userFound($user, $socialiteUser); } catch (ModelNotFoundException $exception) { - return $this->userNotFound(new GithubUser($socialiteUser->user)); + return $this->userNotFound(new GithubUser($socialiteUser->getRaw())); } + + return $this->userFound($user, $socialiteUser); + } + + private function getSocialiteUser(): SocialiteUser + { + return Socialite::driver('github')->user(); } private function userFound(User $user, SocialiteUser $socialiteUser): RedirectResponse { - $this->dispatchNow(new UpdateProfile($user, ['github_username' => $socialiteUser->nickname])); + $this->dispatchNow(new UpdateProfile($user, ['github_username' => $socialiteUser->getNickname()])); Auth::login($user); diff --git a/app/Http/Controllers/Forum/ThreadsController.php b/app/Http/Controllers/Forum/ThreadsController.php index 9fad663c5..9c074f9b8 100644 --- a/app/Http/Controllers/Forum/ThreadsController.php +++ b/app/Http/Controllers/Forum/ThreadsController.php @@ -8,13 +8,16 @@ use App\Jobs\CreateThread; use App\Jobs\DeleteThread; use App\Jobs\UpdateThread; +use Illuminate\Http\Request; use App\Policies\ThreadPolicy; use App\Queries\SearchThreads; use App\Jobs\MarkThreadSolution; use App\Jobs\UnmarkThreadSolution; use App\Http\Controllers\Controller; use App\Http\Requests\ThreadRequest; +use App\Jobs\SubscribeToSubscriptionAble; use Illuminate\Auth\Middleware\Authenticate; +use App\Jobs\UnsubscribeFromSubscriptionAble; use App\Http\Middleware\RedirectIfUnconfirmed; class ThreadsController extends Controller @@ -97,4 +100,26 @@ public function unmarkSolution(Thread $thread) return redirect()->route('thread', $thread->slug()); } + + public function subscribe(Request $request, Thread $thread) + { + $this->authorize(ThreadPolicy::SUBSCRIBE, $thread); + + $this->dispatchNow(new SubscribeToSubscriptionAble($request->user(), $thread)); + + $this->success("You're now subscribed to this thread."); + + return redirect()->route('thread', $thread->slug()); + } + + public function unsubscribe(Request $request, Thread $thread) + { + $this->authorize(ThreadPolicy::UNSUBSCRIBE, $thread); + + $this->dispatchNow(new UnsubscribeFromSubscriptionAble($request->user(), $thread)); + + $this->success("You're now unsubscribed from this thread."); + + return redirect()->route('thread', $thread->slug()); + } } diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php new file mode 100644 index 000000000..a0f49726d --- /dev/null +++ b/app/Http/Controllers/SubscriptionController.php @@ -0,0 +1,20 @@ +subscriptionAble(); + + $this->dispatch(new UnsubscribeFromSubscriptionAble($subscription->user(), $thread)); + + $this->success("You're now unsubscribed from this thread."); + + return redirect()->route('thread', $thread->slug()); + } +} diff --git a/app/Jobs/CreateReply.php b/app/Jobs/CreateReply.php index ef60b3c5f..1d8a664eb 100644 --- a/app/Jobs/CreateReply.php +++ b/app/Jobs/CreateReply.php @@ -4,7 +4,11 @@ use App\User; use App\Models\Reply; +use Ramsey\Uuid\Uuid; use App\Models\ReplyAble; +use App\Models\Subscription; +use App\Events\ReplyWasCreated; +use App\Models\SubscriptionAble; use App\Http\Requests\CreateReplyRequest; final class CreateReply @@ -49,6 +53,17 @@ public function handle(): Reply $reply->to($this->replyAble); $reply->save(); + event(new ReplyWasCreated($reply)); + + if ($this->replyAble instanceof SubscriptionAble && ! $this->replyAble->hasSubscriber($this->author)) { + $subscription = new Subscription(); + $subscription->uuid = Uuid::uuid4()->toString(); + $subscription->userRelation()->associate($this->author); + $subscription->subscriptionAbleRelation()->associate($this->replyAble); + + $this->replyAble->subscriptionsRelation()->save($subscription); + } + return $reply; } } diff --git a/app/Jobs/CreateThread.php b/app/Jobs/CreateThread.php index e4e98835e..3abe5dd42 100644 --- a/app/Jobs/CreateThread.php +++ b/app/Jobs/CreateThread.php @@ -3,7 +3,9 @@ namespace App\Jobs; use App\User; +use Ramsey\Uuid\Uuid; use App\Models\Thread; +use App\Models\Subscription; use App\Http\Requests\ThreadRequest; final class CreateThread @@ -65,6 +67,14 @@ public function handle(): Thread $thread->syncTags($this->tags); $thread->save(); + // Subscribe author to the thread. + $subscription = new Subscription(); + $subscription->uuid = Uuid::uuid4()->toString(); + $subscription->userRelation()->associate($this->author); + $subscription->subscriptionAbleRelation()->associate($thread); + + $thread->subscriptionsRelation()->save($subscription); + return $thread; } } diff --git a/app/Jobs/SendEmailConfirmation.php b/app/Jobs/SendEmailConfirmation.php index d674d3457..e4d04d0d4 100644 --- a/app/Jobs/SendEmailConfirmation.php +++ b/app/Jobs/SendEmailConfirmation.php @@ -3,7 +3,7 @@ namespace App\Jobs; use App\User; -use App\Mail\EmailConfirmation; +use App\Mail\EmailConfirmationEmail; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Queue\SerializesModels; @@ -24,6 +24,6 @@ public function __construct(User $user) public function handle(Mailer $mailer) { $mailer->to($this->user->emailAddress()) - ->send(new EmailConfirmation($this->user)); + ->send(new EmailConfirmationEmail($this->user)); } } diff --git a/app/Jobs/SubscribeToSubscriptionAble.php b/app/Jobs/SubscribeToSubscriptionAble.php new file mode 100644 index 000000000..c57ffe79f --- /dev/null +++ b/app/Jobs/SubscribeToSubscriptionAble.php @@ -0,0 +1,35 @@ +user = $user; + $this->subscriptionAble = $subscriptionAble; + } + + public function handle() + { + $subscription = new Subscription(); + $subscription->uuid = Uuid::uuid4()->toString(); + $subscription->userRelation()->associate($this->user); + $this->subscriptionAble->subscriptionsRelation()->save($subscription); + } +} diff --git a/app/Jobs/UnsubscribeFromSubscriptionAble.php b/app/Jobs/UnsubscribeFromSubscriptionAble.php new file mode 100644 index 000000000..1f4fc5422 --- /dev/null +++ b/app/Jobs/UnsubscribeFromSubscriptionAble.php @@ -0,0 +1,32 @@ +user = $user; + $this->subscriptionAble = $subscriptionAble; + } + + public function handle() + { + $this->subscriptionAble->subscriptionsRelation() + ->where('user_id', $this->user->id()) + ->delete(); + } +} diff --git a/app/Listeners/SendNewReplyNotification.php b/app/Listeners/SendNewReplyNotification.php new file mode 100644 index 000000000..9779d3daf --- /dev/null +++ b/app/Listeners/SendNewReplyNotification.php @@ -0,0 +1,24 @@ +reply->replyAble()->subscriptions() as $subscription) { + if ($this->replyAuthorDoesNotMatchSubscriber($event->reply->author(), $subscription)) { + $subscription->user()->notify(new NewReplyNotification($event->reply, $subscription)); + } + } + } + + private function replyAuthorDoesNotMatchSubscriber(User $author, $subscription): bool + { + return ! $author->matches($subscription->user()); + } +} diff --git a/app/Mail/EmailConfirmation.php b/app/Mail/EmailConfirmationEmail.php similarity index 89% rename from app/Mail/EmailConfirmation.php rename to app/Mail/EmailConfirmationEmail.php index 24a0c7bcc..d1910d69a 100644 --- a/app/Mail/EmailConfirmation.php +++ b/app/Mail/EmailConfirmationEmail.php @@ -6,7 +6,7 @@ use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -final class EmailConfirmation extends Mailable +final class EmailConfirmationEmail extends Mailable { use SerializesModels; diff --git a/app/Mail/NewReplyEmail.php b/app/Mail/NewReplyEmail.php new file mode 100644 index 000000000..14e111551 --- /dev/null +++ b/app/Mail/NewReplyEmail.php @@ -0,0 +1,37 @@ +reply = $reply; + $this->subscription = $subscription; + } + + public function build() + { + return $this->subject("Re: {$this->reply->replyAble()->subject()}") + ->markdown('emails.new_reply'); + } +} diff --git a/app/Models/ReplyAble.php b/app/Models/ReplyAble.php index ecf459c18..1a81ea369 100644 --- a/app/Models/ReplyAble.php +++ b/app/Models/ReplyAble.php @@ -9,6 +9,8 @@ */ interface ReplyAble { + public function subject(): string; + /** * @return \App\Models\Reply[] */ diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 000000000..afe57eee9 --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,39 @@ +userRelation; + } + + public function userRelation(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function subscriptionAble(): SubscriptionAble + { + return $this->subscriptionAbleRelation; + } + + public function subscriptionAbleRelation(): MorphTo + { + return $this->morphTo('subscriptionable'); + } +} diff --git a/app/Models/SubscriptionAble.php b/app/Models/SubscriptionAble.php new file mode 100644 index 000000000..b423d63f4 --- /dev/null +++ b/app/Models/SubscriptionAble.php @@ -0,0 +1,18 @@ +reply = $reply; + $this->subscription = $subscription; + } + + public function via(User $user) + { + return ['mail']; + } + + public function toMail(User $user) + { + return (new NewReplyEmail($this->reply, $this->subscription)) + ->to($user->emailAddress(), $user->name()); + } + + public function toArray(User $user) + { + return [ + // + ]; + } +} diff --git a/app/Policies/ThreadPolicy.php b/app/Policies/ThreadPolicy.php index 93caa26ae..18d19cf76 100644 --- a/app/Policies/ThreadPolicy.php +++ b/app/Policies/ThreadPolicy.php @@ -9,20 +9,26 @@ class ThreadPolicy { const UPDATE = 'update'; const DELETE = 'delete'; + const SUBSCRIBE = 'subscribe'; + const UNSUBSCRIBE = 'unsubscribe'; - /** - * Determine if the given thread can be updated by the user. - */ public function update(User $user, Thread $thread): bool { return $thread->isAuthoredBy($user) || $user->isModerator() || $user->isAdmin(); } - /** - * Determine if the given thread can be deleted by the user. - */ public function delete(User $user, Thread $thread): bool { return $thread->isAuthoredBy($user) || $user->isModerator() || $user->isAdmin(); } + + public function subscribe(User $user, Thread $thread): bool + { + return ! $thread->hasSubscriber($user); + } + + public function unsubscribe(User $user, Thread $thread): bool + { + return $thread->hasSubscriber($user); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 000000000..3dfb5e070 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,21 @@ + [ + SendNewReplyNotification::class, + ], + ]; +} diff --git a/composer.json b/composer.json index 2f49d439b..57574637e 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravelcollective/html": "5.5.*", "lasserafn/php-initial-avatar-generator": "^2.0", "league/commonmark": "^0.15.2", + "ramsey/uuid": "^3.7", "roave/security-advisories": "dev-master", "spatie/laravel-robots-middleware": "^1.0", "tijsverkoyen/akismet": "^1.1" diff --git a/composer.lock b/composer.lock index 40534b1c6..92b23e404 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "282ec772f39fae2f400f1c534bcc2b33", + "content-hash": "1702397b8fdfade09b0a7f5f439c384b", "packages": [ { "name": "bugsnag/bugsnag", @@ -1355,16 +1355,16 @@ }, { "name": "laravel/framework", - "version": "v5.5.14", + "version": "v5.5.24", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "26c700eb79e5bb55b59df2c495c9c71f16f43302" + "reference": "06135405bb1f736dac5e9529ed1541fc446c9c0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/26c700eb79e5bb55b59df2c495c9c71f16f43302", - "reference": "26c700eb79e5bb55b59df2c495c9c71f16f43302", + "url": "https://api.github.com/repos/laravel/framework/zipball/06135405bb1f736dac5e9529ed1541fc446c9c0f", + "reference": "06135405bb1f736dac5e9529ed1541fc446c9c0f", "shasum": "" }, "require": { @@ -1405,7 +1405,6 @@ "illuminate/database": "self.version", "illuminate/encryption": "self.version", "illuminate/events": "self.version", - "illuminate/exception": "self.version", "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", @@ -1439,6 +1438,8 @@ "suggest": { "aws/aws-sdk-php": "Required to use the SQS queue driver and SES mail driver (~3.0).", "doctrine/dbal": "Required to rename columns and drop SQLite columns (~2.5).", + "ext-pcntl": "Required to use all features of the queue worker.", + "ext-posix": "Required to use all features of the queue worker.", "fzaninotto/faker": "Required to use the eloquent factory builder (~1.4).", "guzzlehttp/guzzle": "Required to use the Mailgun and Mandrill mail drivers and the ping methods on schedules (~6.0).", "laravel/tinker": "Required to use the tinker console command (~1.0).", @@ -1447,7 +1448,7 @@ "nexmo/client": "Required to use the Nexmo transport (~1.0).", "pda/pheanstalk": "Required to use the beanstalk queue driver (~3.0).", "predis/predis": "Required to use the redis cache and queue drivers (~1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (~2.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (~3.0).", "symfony/css-selector": "Required to use some of the crawler integration testing tools (~3.3).", "symfony/dom-crawler": "Required to use most of the crawler integration testing tools (~3.3).", "symfony/psr-http-message-bridge": "Required to psr7 bridging features (~1.0)." @@ -1483,7 +1484,7 @@ "framework", "laravel" ], - "time": "2017-10-03T17:41:03+00:00" + "time": "2017-12-07T01:28:21+00:00" }, { "name": "laravel/socialite", @@ -2574,16 +2575,16 @@ }, { "name": "ramsey/uuid", - "version": "3.7.1", + "version": "3.7.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "45cffe822057a09e05f7bd09ec5fb88eeecd2334" + "reference": "44abcdad877d9a46685a3a4d221e3b2c4b87cb76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/45cffe822057a09e05f7bd09ec5fb88eeecd2334", - "reference": "45cffe822057a09e05f7bd09ec5fb88eeecd2334", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/44abcdad877d9a46685a3a4d221e3b2c4b87cb76", + "reference": "44abcdad877d9a46685a3a4d221e3b2c4b87cb76", "shasum": "" }, "require": { @@ -2594,17 +2595,15 @@ "rhumsaa/uuid": "self.version" }, "require-dev": { - "apigen/apigen": "^4.1", - "codeception/aspect-mock": "^1.0 | ^2.0", + "codeception/aspect-mock": "^1.0 | ~2.0.0", "doctrine/annotations": "~1.2.0", "goaop/framework": "1.0.0-alpha.2 | ^1.0 | ^2.1", "ircmaxell/random-lib": "^1.1", "jakub-onderka/php-parallel-lint": "^0.9.0", - "mockery/mockery": "^0.9.4", + "mockery/mockery": "^0.9.9", "moontoast/math": "^1.1", "php-mock/php-mock-phpunit": "^0.3|^1.1", - "phpunit/phpunit": "^4.7|>=5.0 <5.4", - "satooshi/php-coveralls": "^0.6.1", + "phpunit/phpunit": "^4.7|^5.0", "squizlabs/php_codesniffer": "^2.3" }, "suggest": { @@ -2652,7 +2651,7 @@ "identifier", "uuid" ], - "time": "2017-09-22T20:46:04+00:00" + "time": "2018-01-20T00:28:24+00:00" }, { "name": "roave/security-advisories", diff --git a/config/app.php b/config/app.php index 2e2f02849..2e0920e34 100644 --- a/config/app.php +++ b/config/app.php @@ -173,6 +173,7 @@ */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, + App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, App\Markdown\MarkdownServiceProvider::class, App\Spam\SpamServiceProvider::class, diff --git a/database/factories/SubscriptionFactory.php b/database/factories/SubscriptionFactory.php new file mode 100644 index 000000000..39294f29a --- /dev/null +++ b/database/factories/SubscriptionFactory.php @@ -0,0 +1,15 @@ +define(Subscription::class, function (Faker\Generator $faker, array $attributes = []) { + return [ + 'uuid' => Uuid::uuid4()->toString(), + 'user_id' => $attributes['user_id'] ?? factory(User::class)->create()->id(), + 'subscriptionable_id' => $attributes['subscriptionable_id'] ?? factory(Thread::class)->create()->id(), + 'subscriptionable_type' => Thread::TABLE, + ]; +}); diff --git a/database/migrations/2017_10_18_193001_create_subscriptions_table.php b/database/migrations/2017_10_18_193001_create_subscriptions_table.php new file mode 100644 index 000000000..e1f874854 --- /dev/null +++ b/database/migrations/2017_10_18_193001_create_subscriptions_table.php @@ -0,0 +1,27 @@ +uuid('uuid'); + $table->primary('uuid'); + $table->integer('user_id')->unsigned(); + $table->integer('subscriptionable_id'); + $table->string('subscriptionable_type')->default(''); + $table->timestamps(); + }); + + Schema::table('subscriptions', function (Blueprint $table) { + $table->index(['user_id', 'uuid']); + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('CASCADE'); + }); + } +} diff --git a/resources/views/emails/new_reply.blade.php b/resources/views/emails/new_reply.blade.php new file mode 100644 index 000000000..fe3108d3c --- /dev/null +++ b/resources/views/emails/new_reply.blade.php @@ -0,0 +1,13 @@ +@component('mail::message') + +**{{ $reply->author()->name() }}** has replied to this thread. + +@component('mail::panel') +@md($reply->body()) +@endcomponent + +You are receiving this because you are subscribed to this thread.
+[View it on Laravel.io]({{ route('thread', $reply->replyAble()->slug()) }}), +or [unsubscribe]({{ route('subscriptions.unsubscribe', $subscription->uuid()->toString()) }}). + +@endcomponent diff --git a/resources/views/forum/threads/show.blade.php b/resources/views/forum/threads/show.blade.php index c9f83b0c4..586a7feb8 100644 --- a/resources/views/forum/threads/show.blade.php +++ b/resources/views/forum/threads/show.blade.php @@ -10,11 +10,25 @@
@can(App\Policies\ThreadPolicy::UPDATE, $thread) - Edit + + Edit + + @endcan + + @can(App\Policies\ThreadPolicy::UNSUBSCRIBE, $thread) + + Unsubscribe + + @elsecan(App\Policies\ThreadPolicy::SUBSCRIBE, $thread) + + Subscribe + @endcan @can(App\Policies\ThreadPolicy::DELETE, $thread) - Delete + + Delete + @include('_partials._delete_modal', [ 'id' => 'deleteThread', @@ -24,7 +38,9 @@ ]) @endcan - Back + + Back + @include('layouts._ads._forum_sidebar') @@ -57,18 +73,26 @@ @include('forum.threads.info.avatar', ['user' => $reply->author()])
- {{ $reply->author()->name() }} replied + + {{ $reply->author()->name() }} + replied {{ $reply->createdAt()->diffForHumans() }} @if ($thread->isSolutionReply($reply)) - Solution + + Solution + @endif
@can(App\Policies\ReplyPolicy::UPDATE, $reply)
- Edit - Delete + + Edit + + + Delete +
@endcan diff --git a/routes/bindings.php b/routes/bindings.php index cd2f7d22e..317535db6 100644 --- a/routes/bindings.php +++ b/routes/bindings.php @@ -1,17 +1,23 @@ 'threads.delete', 'uses' => 'ThreadsController@delete']); Route::put('{thread}/mark-solution/{reply}', ['as' => 'threads.solution.mark', 'uses' => 'ThreadsController@markSolution']); Route::put('{thread}/unmark-solution', ['as' => 'threads.solution.unmark', 'uses' => 'ThreadsController@unmarkSolution']); + Route::get('{thread}/subscribe', ['as' => 'threads.subscribe', 'uses' => 'ThreadsController@subscribe']); + Route::get('{thread}/unsubscribe', ['as' => 'threads.unsubscribe', 'uses' => 'ThreadsController@unsubscribe']); Route::get('tags/{tag}', ['as' => 'forum.tag', 'uses' => 'TagsController@show']); }); @@ -64,6 +66,9 @@ Route::put('replies/{reply}', ['as' => 'replies.update', 'uses' => 'ReplyController@update']); Route::delete('replies/{reply}', ['as' => 'replies.delete', 'uses' => 'ReplyController@delete']); +// Subscriptions +Route::get('subscriptions/{subscription}/unsubscribe', ['as' => 'subscriptions.unsubscribe', 'uses' => 'SubscriptionController@unsubscribe']); + // Admin Route::group(['prefix' => 'admin', 'as' => 'admin', 'namespace' => 'Admin'], function () { Route::get('/', 'AdminController@index'); diff --git a/tests/BrowserKitTestCase.php b/tests/BrowserKitTestCase.php index 6b8436098..f8f53ab1c 100644 --- a/tests/BrowserKitTestCase.php +++ b/tests/BrowserKitTestCase.php @@ -6,7 +6,12 @@ abstract class BrowserKitTestCase extends BaseTestCase { - use CreatesApplication, CreatesUsers, HttpAssertions; + use CreatesApplication, CreatesUsers, FakesData, HttpAssertions; public $baseUrl = 'http://localhost'; + + protected function dispatch($job) + { + return $job->handle(); + } } diff --git a/tests/Components/Jobs/CreateReplyTest.php b/tests/Components/Jobs/CreateReplyTest.php index 4848b9480..09b0782b7 100644 --- a/tests/Components/Jobs/CreateReplyTest.php +++ b/tests/Components/Jobs/CreateReplyTest.php @@ -5,6 +5,7 @@ use Tests\TestCase; use App\Models\Thread; use App\Jobs\CreateReply; +use App\Events\ReplyWasCreated; use Illuminate\Foundation\Testing\DatabaseMigrations; class CreateReplyTest extends TestCase @@ -17,6 +18,8 @@ public function we_can_create_a_reply() $user = $this->createUser(); $thread = factory(Thread::class)->create(); + $this->expectsEvents(ReplyWasCreated::class); + $reply = $this->dispatch(new CreateReply('Foo', '', $user, $thread)); $this->assertEquals('Foo', $reply->body()); diff --git a/tests/Components/Jobs/SubscribeToSubscriptionAbleTest.php b/tests/Components/Jobs/SubscribeToSubscriptionAbleTest.php new file mode 100644 index 000000000..f992ec044 --- /dev/null +++ b/tests/Components/Jobs/SubscribeToSubscriptionAbleTest.php @@ -0,0 +1,26 @@ +createUser(); + $thread = factory(Thread::class)->create(); + + $this->assertFalse($thread->hasSubscriber($user)); + + $this->dispatch(new SubscribeToSubscriptionAble($user, $thread)); + + $this->assertTrue($thread->hasSubscriber($user)); + } +} diff --git a/tests/Components/Jobs/UnsubscribeFromSubscriptionAbleTest.php b/tests/Components/Jobs/UnsubscribeFromSubscriptionAbleTest.php new file mode 100644 index 000000000..3543a6750 --- /dev/null +++ b/tests/Components/Jobs/UnsubscribeFromSubscriptionAbleTest.php @@ -0,0 +1,28 @@ +createUser(); + $thread = factory(Thread::class)->create(); + factory(Subscription::class)->create(['user_id' => $user->id(), 'subscriptionable_id' => $thread->id()]); + + $this->assertTrue($thread->hasSubscriber($user)); + + $this->dispatch(new UnsubscribeFromSubscriptionAble($user, $thread)); + + $this->assertFalse($thread->hasSubscriber($user)); + } +} diff --git a/tests/Components/Models/SubscriptionTest.php b/tests/Components/Models/SubscriptionTest.php new file mode 100644 index 000000000..5d5a3d124 --- /dev/null +++ b/tests/Components/Models/SubscriptionTest.php @@ -0,0 +1,22 @@ +create()->uuid(); + + $subscription = Subscription::findByUuidOrFail($uuid); + + $this->assertEquals($uuid, $subscription->uuid()); + } +} diff --git a/tests/FakesData.php b/tests/FakesData.php new file mode 100644 index 000000000..8c74b8c84 --- /dev/null +++ b/tests/FakesData.php @@ -0,0 +1,29 @@ +faker = Factory::create(); + } + + /** + * @after + */ + public function unsetFaker() + { + $this->faker = null; + } +} diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php index 81a8495a7..07d9c56c2 100644 --- a/tests/Feature/AuthTest.php +++ b/tests/Feature/AuthTest.php @@ -6,7 +6,7 @@ use Mail; use Carbon\Carbon; use Tests\BrowserKitTestCase; -use App\Mail\EmailConfirmation; +use App\Mail\EmailConfirmationEmail; use Illuminate\Contracts\Auth\PasswordBroker; use Illuminate\Foundation\Testing\DatabaseMigrations; @@ -34,7 +34,7 @@ public function users_can_register() $this->assertLoggedIn(); - Mail::assertSent(EmailConfirmation::class); + Mail::assertSent(EmailConfirmationEmail::class); } /** @test */ diff --git a/tests/Feature/SubscriptionsTest.php b/tests/Feature/SubscriptionsTest.php new file mode 100644 index 000000000..5f69d9ab3 --- /dev/null +++ b/tests/Feature/SubscriptionsTest.php @@ -0,0 +1,124 @@ +create(); + [$author, $userOne, $userTwo] = factory(User::class)->times(3)->create(); + factory(Subscription::class)->create(['user_id' => $userOne->id(), 'subscriptionable_id' => $thread->id()]); + factory(Subscription::class)->create(['user_id' => $userTwo->id(), 'subscriptionable_id' => $thread->id()]); + + $this->dispatch(new CreateReply($this->faker->text, $this->faker->ipv4, $author, $thread)); + + Notification::assertNotSentTo($author, NewReplyNotification::class); + Notification::assertSentTo([$userOne, $userTwo], NewReplyNotification::class); + } + + /** @test */ + public function users_are_automatically_subscribed_to_a_thread_after_creating_it() + { + $user = $this->createUser(); + + $thread = $this->dispatch( + new CreateThread($this->faker->sentence, $this->faker->text, $this->faker->ipv4, $user) + ); + + $this->assertTrue($thread->hasSubscriber($user)); + } + + /** @test */ + public function thread_authors_do_not_receive_a_notification_for_a_thread_they_create() + { + Notification::fake(); + + $author = $this->createUser(); + + $this->dispatch(new CreateThread($this->faker->sentence, $this->faker->text, $this->faker->ipv4, $author)); + + Notification::assertNotSentTo($author, NewReplyNotification::class); + } + + /** @test */ + public function reply_authors_do_not_receive_a_notification_for_a_thread_they_are_subscribed_to() + { + Notification::fake(); + + $thread = factory(Thread::class)->create(); + $author = factory(User::class)->create(); + factory(Subscription::class)->create(['user_id' => $author->id(), 'subscriptionable_id' => $thread->id()]); + + $this->dispatch(new CreateReply($this->faker->text, $this->faker->ipv4, $author, $thread)); + + Notification::assertNotSentTo($author, NewReplyNotification::class); + } + + /** @test */ + public function users_are_automatically_subscribed_to_a_thread_after_replying_to_it() + { + $user = $this->createUser(); + $thread = factory(Thread::class)->create(); + + $this->dispatch(new CreateReply($this->faker->text, $this->faker->ipv4, $user, $thread)); + + $this->assertTrue($thread->hasSubscriber($user)); + } + + /** @test */ + public function users_can_manually_subscribe_to_threads() + { + factory(Thread::class)->create(['slug' => $slug = $this->faker->slug]); + + $this->login(); + + $this->visit("/forum/$slug") + ->click('Subscribe') + ->seePageIs("/forum/$slug") + ->see("You're now subscribed to this thread."); + } + + /** @test */ + public function users_can_unsubscribe_from_threads() + { + $user = $this->createUser(); + $thread = factory(Thread::class)->create(['slug' => $slug = $this->faker->slug]); + factory(Subscription::class)->create(['user_id' => $user->id(), 'subscriptionable_id' => $thread->id()]); + + $this->loginAs($user); + + $this->visit("/forum/$slug") + ->click('Unsubscribe') + ->seePageIs("/forum/$slug") + ->see("You're now unsubscribed from this thread."); + } + + /** @test */ + public function users_can_unsubscribe_through_a_token_link() + { + $subscription = factory(Subscription::class)->create(); + $thread = $subscription->subscriptionAble(); + + $this->visit("/subscriptions/{$subscription->uuid()}/unsubscribe") + ->seePageIs("/forum/{$thread->slug()}") + ->see("You're now unsubscribed from this thread."); + + $this->notSeeInDatabase('subscriptions', ['uuid' => $subscription->uuid()]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d77f1880d..2a7c572cb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ abstract class TestCase extends IlluminateTestCase { - use CreatesApplication, CreatesUsers, HttpAssertions; + use CreatesApplication, CreatesUsers, FakesData, HttpAssertions; protected function dispatch($job) {