├── .github └── workflows │ └── main.yml ├── .gitignore ├── .php_cs.dist ├── composer.json ├── infection.json.dist ├── migrations └── 2019_08_25_102639_create_throttled_notifications_table.php ├── phpstan.neon ├── phpunit.xml.dist ├── psalm.xml ├── readme.md ├── src ├── Builders │ ├── DatabaseNotificationBuilder.php │ └── ThrottledNotificationBuilder.php ├── Contracts │ ├── Courier.php │ ├── Delay.php │ ├── Notifiables.php │ ├── Reservables.php │ ├── Throttleable.php │ └── Wait.php ├── Courier.php ├── Delay.php ├── Jobs │ ├── SendThrottledNotifications.php │ └── SendThrottledNotificationsToNotifiable.php ├── Models │ ├── DatabaseNotification.php │ └── ThrottledNotification.php ├── Notifiable.php ├── Queries │ ├── DatabaseNotifications.php │ ├── Notifiables.php │ ├── Reservables.php │ └── ThrottledNotifications.php ├── ServiceProvider.php ├── ThrottleChannel.php ├── Throttleable.php ├── ThrottledNotificationCollection.php └── Wait.php └── tests ├── CourierFake.php ├── Feature ├── NotifiablesTest.php ├── ReservablesTest.php ├── SendThrottledNotificationsTest.php ├── SendThrottledNotificationsToNotifiableTest.php └── ThrottleChannelTest.php ├── Notifiable.php ├── Notification.php ├── TestCase.php ├── Unit ├── DelayTest.php ├── ThrottledNotificationTest.php └── WaitTest.php ├── factories ├── DatabaseNotificationFactory.php ├── NotifiableFactory.php └── ThrottledNotificationFactory.php └── migrations ├── 2019_08_25_093633_create_notifiables_table.php └── 2019_08_25_093633_create_notifications_table.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | name: Tests 13 | steps: 14 | - name: checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Cache dependencies 18 | uses: actions/cache@v1 19 | with: 20 | path: ~/.composer/cache/files 21 | key: dependencies-composer-${{ hashFiles('composer.json') }} 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: 7.3 27 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite 28 | coverage: none 29 | 30 | - name: Install Composer dependencies 31 | run: composer install --prefer-dist --no-interaction --no-suggest 32 | 33 | - name: Execute tests 34 | run: ./vendor/bin/phpunit --verbose 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.phpunit.result.cache 4 | /.php_cs.cache 5 | /coverage 6 | /infection.log 7 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | in(__DIR__.'/migrations') 5 | ->in(__DIR__.'/src') 6 | ->in(__DIR__.'/tests'); 7 | 8 | return style_rules($finder); 9 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timacdonald/notification-throttle", 3 | "authors": [ 4 | { 5 | "name": "Tim MacDonald", 6 | "email": "hello@timacdonald.me", 7 | "homepage": "https://timacdonald.me" 8 | } 9 | ], 10 | "license": "MIT", 11 | "require": { 12 | "php": "^7.2", 13 | "orchestra/testbench": "^5.0", 14 | "phpunit/phpunit": "^9.0" 15 | }, 16 | "require-dev": { 17 | "infection/infection": "^0.18.2", 18 | "phpstan/phpstan": "^0.12", 19 | "timacdonald/callable-fake": "^1.0", 20 | "timacdonald/php-style": "dev-master", 21 | "vimeo/psalm": "^4.1" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "TiMacDonald\\ThrottledNotifications\\": "src/" 26 | }, 27 | "classmap": [ 28 | "migrations" 29 | ] 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Tests\\": "tests/" 34 | }, 35 | "classmap": [ 36 | "tests/migrations" 37 | ] 38 | }, 39 | "extra": { 40 | "laravel": { 41 | "providers": [ 42 | "TiMacDonald\\ThrottledNotifications\\ServiceProvider" 43 | ] 44 | } 45 | }, 46 | "scripts": { 47 | "lint": [ 48 | "clear", 49 | "./vendor/bin/php-cs-fixer fix", 50 | "./vendor/bin/psalm", 51 | "./vendor/bin/phpstan analyse" 52 | ], 53 | "test": [ 54 | "clear", 55 | "./vendor/bin/phpunit", 56 | "./vendor/bin/infection --threads=4" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "infection.log", 9 | "badge": { 10 | "branch": "master" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /migrations/2019_08_25_102639_create_throttled_notifications_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 15 | $table->longText('payload'); 16 | $table->dateTime('sent_at')->nullable(); 17 | $table->dateTime('delayed_until')->nullable(); 18 | $table->uuid('reserved_key')->unique()->nullable(); 19 | $table->uuid('notification_id'); 20 | $table->timestamps(); 21 | 22 | $table->foreign('notification_id') 23 | ->references('id') 24 | ->on('notifications'); 25 | }); 26 | } 27 | 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('throttled_notifications'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | checkMissingIterableValueType: false 3 | level: max 4 | paths: 5 | - src 6 | - tests 7 | ignoreErrors: 8 | - 9 | message: '#Undefined variable: \$factory#' 10 | path: 'tests/factories' 11 | - '#Call to an undefined method TiMacDonald\\ThrottledNotifications\\Builders\\[a-zA-Z]+Builder::[a-zA-Z]+()#' 12 | - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder::[a-zA-Z]+()#' 13 | - '#Call to an undefined static method Illuminate\\Support\\Facades\\[a-zA-Z]+::[a-zA-Z]+()#' 14 | - '#Parameter \#1 $key of method TiMacDonald\\ThrottledNotifications\\Queries\\Reservables::[a-zA-z]+() expects string, string|null given.#' 15 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | src 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Bundled Notifications for Laravel 2 | 3 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Ftimacdonald%2Fdebounced-notifications%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/timacdonald/debounced-notifications/master) 4 | 5 | Spamming your users with notifications? Want to reduce the noise your app is creating? You are in the right place! 6 | 7 | This package can bundle transactional notifications you send from your application into a single notification, be it email, text messages, push notifications, or whatever else. Not only does it bundle notifications, you can also implement a do not distrub / work can wait time period in which all notifications will be delayed. After the period is over, all notifications sent during that time will be bundled into a single notification. 8 | 9 | This package is inspired by the functionality offered by basecamp. 10 | 11 | ## How bundling works 12 | 13 | 14 | 15 | 16 | ## My Notes 17 | - No config. Container binding is the config. 18 | - Basic throttle per notifiable 19 | - Work can wait / Do not disturb 20 | 21 | ## Coming later 22 | - Throttle per notification type 23 | - Ordering by notification type 24 | 25 | ## Unknown 26 | - Should i release the reserved notifications when the job fails? 27 | -------------------------------------------------------------------------------- /src/Builders/DatabaseNotificationBuilder.php: -------------------------------------------------------------------------------- 1 | query->whereNull('read_at'); 17 | 18 | return $this; 19 | } 20 | 21 | public function groupByNotifiable(): self 22 | { 23 | $this->groupBy(['notifiable_type', 'notifiable_id']); 24 | 25 | return $this; 26 | } 27 | 28 | public function orderByOldest(): self 29 | { 30 | $this->oldest('notifications.created_at'); 31 | 32 | return $this; 33 | } 34 | 35 | public function selectNotifiable(): self 36 | { 37 | $this->select([ 38 | 'notifications.notifiable_id as '.Notifiable::KEY_ATTRIBUTE, 39 | 'notifications.notifiable_type as '.Notifiable::TYPE_ATTRIBUTE, 40 | ]); 41 | 42 | return $this; 43 | } 44 | 45 | public function joinThrottledNotifications(ThrottledNotificationBuilder $throttledNotifications): self 46 | { 47 | $baseQuery = $throttledNotifications->toBase(); 48 | 49 | $this->join('throttled_notifications', static function (JoinClause $join) use ($baseQuery): void { 50 | $join->on('notifications.id', 'throttled_notifications.notification_id') 51 | ->mergeWheres($baseQuery->wheres, $baseQuery->bindings); 52 | }); 53 | 54 | return $this; 55 | } 56 | 57 | public function whereNotifiable(Model $notifiable): self 58 | { 59 | // TODO: does this need to account for morph map keys? 60 | 61 | $this->where('notifiable_type', '=', \get_class($notifiable)) 62 | ->where('notifiable_id', '=', $notifiable->getKey()); 63 | 64 | return $this; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Builders/ThrottledNotificationBuilder.php: -------------------------------------------------------------------------------- 1 | query->whereNull('sent_at'); 17 | 18 | return $this; 19 | } 20 | 21 | public function wherePastWait(Wait $wait): self 22 | { 23 | $this->where('throttled_notifications.created_at', '<=', $wait->lapsesAt()); 24 | 25 | return $this; 26 | } 27 | 28 | public function whereUnreserved(): self 29 | { 30 | $this->whereNull('reserved_key'); 31 | 32 | return $this; 33 | } 34 | 35 | public function whereNotDelayed(): self 36 | { 37 | $this->whereNull('delayed_until'); 38 | 39 | return $this; 40 | } 41 | 42 | public function whereReservedKey(string $key): self 43 | { 44 | $this->where('reserved_key', '=', $key); 45 | 46 | return $this; 47 | } 48 | 49 | public function whereHasDatabaseNotifications(QueryBuilder $databaseNotifications): self 50 | { 51 | $this->whereHas('databaseNotification', static function (EloquentBuilder $builder) use ($databaseNotifications): void { 52 | $builder->mergeWheres($databaseNotifications->wheres, $databaseNotifications->bindings); 53 | }); 54 | 55 | return $this; 56 | } 57 | 58 | public function reserve(string $key): int 59 | { 60 | return $this->update(['reserved_key' => $key]); 61 | } 62 | 63 | public function release(): int 64 | { 65 | return $this->update(['reserved_key' => null]); 66 | } 67 | 68 | public function markAsSent(): int 69 | { 70 | return $this->update(['sent_at' => Carbon::now()]); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Contracts/Courier.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 22 | } 23 | 24 | public function send(Model $notifiable, ThrottledNotificationCollection $throttledNotifications): void 25 | { 26 | $throttledNotifications 27 | ->groupByChannel($notifiable) 28 | // ->map(static function (ThrottledNotificationCollection $throttledNotifications, string $channel): void { 29 | // return $notification->groupByType(); 30 | ->each(function (Notification $notification) use ($notifiable): void { 31 | $this->dispatcher->send($notifiable, $notification); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Delay.php: -------------------------------------------------------------------------------- 1 | isFuture()) { 18 | return $date; 19 | } 20 | 21 | return null; 22 | } 23 | 24 | private static function asDate(Model $notifiable): Carbon 25 | { 26 | if (\method_exists($notifiable, 'delayNotificationsUntil')) { 27 | $date = $notifiable->delayNotificationsUntil(); 28 | 29 | \assert($date instanceof Carbon); 30 | 31 | return $date; 32 | } 33 | 34 | return Carbon::now(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Jobs/SendThrottledNotifications.php: -------------------------------------------------------------------------------- 1 | each(static function (Model $notifiable) use ($bus): void { 24 | $bus->dispatch(new SendThrottledNotificationsToNotifiable($notifiable, Str::uuid()->toString())); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Jobs/SendThrottledNotificationsToNotifiable.php: -------------------------------------------------------------------------------- 1 | notifiable = $notifiable; 34 | 35 | $this->key = $key; 36 | } 37 | 38 | public function handle(Reservables $reservables, Courier $courier): void 39 | { 40 | $count = $reservables->reserve($this->notifiable, $this->key); 41 | 42 | if ($count === 0) { 43 | return; 44 | } 45 | 46 | $courier->send($this->notifiable, $reservables->get($this->key)); 47 | 48 | $reservables->markAsSent($this->key); 49 | } 50 | 51 | public function failed(Exception $exception): void 52 | { 53 | $reservables = \app(Reservables::class); 54 | 55 | \assert($reservables instanceof Reservables); 56 | 57 | $reservables->release($this->key); 58 | } 59 | 60 | public function notifiable(): Model 61 | { 62 | return $this->notifiable; 63 | } 64 | 65 | public function key(): string 66 | { 67 | return $this->key; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Models/DatabaseNotification.php: -------------------------------------------------------------------------------- 1 | 'datetime', 47 | 'delayed_until' => 'datetime', 48 | ]; 49 | 50 | protected static function booted(): void 51 | { 52 | static::creating(static function (self $instance): void { 53 | $instance->id = Str::uuid()->toString(); 54 | }); 55 | } 56 | 57 | public function databaseNotification(): BelongsTo 58 | { 59 | return $this->belongsTo(DatabaseNotification::class, 'notification_id'); 60 | } 61 | 62 | protected function setPayloadAttribute(Throttleable $notification): void 63 | { 64 | $this->attributes['payload'] = \serialize($notification); 65 | } 66 | 67 | protected function getPayloadAttribute(string $value): Throttleable 68 | { 69 | $notification = \unserialize($value); 70 | 71 | \assert($notification instanceof Throttleable); 72 | 73 | return $notification; 74 | } 75 | 76 | public static function query(): ThrottledNotificationBuilder 77 | { 78 | $query = parent::query(); 79 | 80 | \assert($query instanceof ThrottledNotificationBuilder); 81 | 82 | return $query; 83 | } 84 | 85 | public function newEloquentBuilder($query): ThrottledNotificationBuilder 86 | { 87 | return new ThrottledNotificationBuilder($query); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Notifiable.php: -------------------------------------------------------------------------------- 1 | {self::TYPE_ATTRIBUTE}; 23 | 24 | \assert(\is_string($type)); 25 | 26 | $class = Model::getActualClassNameForMorph($type); 27 | 28 | $instance = $class::newModelInstance(); 29 | 30 | \assert($instance instanceof Model); 31 | 32 | return $instance->forceFill([$instance->getKeyName() => $record->{static::KEY_ATTRIBUTE}]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Queries/DatabaseNotifications.php: -------------------------------------------------------------------------------- 1 | whereUnread(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Queries/Notifiables.php: -------------------------------------------------------------------------------- 1 | throttledNotifications = $throttledNotifications; 34 | 35 | $this->databaseNotifications = $databaseNotifications; 36 | 37 | $this->wait = $wait; 38 | } 39 | 40 | public function each(callable $callback): void 41 | { 42 | $this->query() 43 | ->toBase() 44 | ->each(static function (stdClass $record) use ($callback): void { 45 | $callback(Notifiable::hydrate($record)); 46 | }); 47 | } 48 | 49 | private function query(): DatabaseNotificationBuilder 50 | { 51 | return $this->databaseNotifications->query() 52 | ->orderByOldest() 53 | ->groupByNotifiable() 54 | ->selectNotifiable() 55 | ->joinThrottledNotifications($this->throttledNotifications()); 56 | } 57 | 58 | private function throttledNotifications(): ThrottledNotificationBuilder 59 | { 60 | return $this->throttledNotifications->query() 61 | ->wherePastWait($this->wait); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Queries/Reservables.php: -------------------------------------------------------------------------------- 1 | databaseNotifications = $databaseNotifications; 29 | 30 | $this->throttledNotifications = $throttledNotifications; 31 | } 32 | 33 | public function reserve(Model $notifiable, string $key): int 34 | { 35 | return $this->throttledNotifications->query() 36 | ->whereHasDatabaseNotifications($this->databaseNotifications($notifiable)->toBase()) 37 | ->reserve($key); 38 | } 39 | 40 | public function get(string $key): ThrottledNotificationCollection 41 | { 42 | return new ThrottledNotificationCollection($this->reservedThrottledNotifications($key)->get()); 43 | } 44 | 45 | public function release(string $key): int 46 | { 47 | return $this->reservedThrottledNotifications($key)->release(); 48 | } 49 | 50 | public function markAsSent(string $key): int 51 | { 52 | return $this->reservedThrottledNotifications($key)->markAsSent(); 53 | } 54 | 55 | private function reservedThrottledNotifications(string $key): ThrottledNotificationBuilder 56 | { 57 | return ThrottledNotification::query() 58 | ->whereReservedKey($key) 59 | ->oldest(); 60 | } 61 | 62 | private function databaseNotifications(Model $notifiable): DatabaseNotificationBuilder 63 | { 64 | return $this->databaseNotifications->query() 65 | ->whereNotifiable($notifiable); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Queries/ThrottledNotifications.php: -------------------------------------------------------------------------------- 1 | whereUnsent() 16 | ->whereNotDelayed() 17 | ->whereUnreserved(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 14 | $this->loadMigrationsFrom(__DIR__.'/../migrations'); 15 | } 16 | } 17 | 18 | public function register(): void 19 | { 20 | $this->app->bind(Contracts\Wait::class, static function () { 21 | return Wait::fromMinutes(10); 22 | }); 23 | 24 | $this->app->bind(Contracts\Delay::class, Delay::class); 25 | 26 | $this->app->bind(Contracts\Courier::class, Courier::class); 27 | 28 | $this->app->bind(Contracts\Notifiables::class, Queries\Notifiables::class); 29 | 30 | $this->app->bind(Contracts\Reservables::class, Queries\Reservables::class); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ThrottleChannel.php: -------------------------------------------------------------------------------- 1 | databaseChannel = $databaseChannel; 28 | 29 | $this->delay = $delay; 30 | } 31 | 32 | public function send(Model $notifiable, Notification $notification): ThrottledNotification 33 | { 34 | $notification = ThrottledNotification::query()->create([ 35 | 'payload' => $notification, 36 | 'delayed_until' => $this->delay->until($notifiable), 37 | 'notification_id' => $this->databaseChannel->send($notifiable, $notification)->getKey(), 38 | ]); 39 | 40 | \assert($notification instanceof ThrottledNotification); 41 | 42 | return $notification; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Throttleable.php: -------------------------------------------------------------------------------- 1 | groupBy(static function (ThrottledNotification $notification) use ($notifiable): array { 16 | return $notification->payload->throttledVia($notifiable); 17 | }); 18 | } 19 | 20 | public function groupByNotificationType(): self 21 | { 22 | return $this->groupBy(static function (ThrottledNotification $notification): string { 23 | return \get_class($notification->payload); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Wait.php: -------------------------------------------------------------------------------- 1 | seconds = $seconds; 20 | } 21 | 22 | public static function fromMinutes(int $minutes): self 23 | { 24 | return new self($minutes * Carbon::SECONDS_PER_MINUTE); 25 | } 26 | 27 | public function lapsesAt(): Carbon 28 | { 29 | return Carbon::now()->subSeconds($this->seconds); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/CourierFake.php: -------------------------------------------------------------------------------- 1 | sent[] = [$notifiable, $notifications]; 23 | } 24 | 25 | public function assertNothingSent(): void 26 | { 27 | Assert::assertCount(0, $this->sent); 28 | } 29 | 30 | public function assertSent(callable $callback): void 31 | { 32 | Assert::assertTrue($this->sent($callback)->isNotEmpty()); 33 | } 34 | 35 | private function sent(callable $callback): Collection 36 | { 37 | return Collection::make($this->sent)->filter(static function (array $tuple) use ($callback): bool { 38 | list($model, $notifications) = $tuple; 39 | 40 | return $callback($model, $notifications); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Feature/NotifiablesTest.php: -------------------------------------------------------------------------------- 1 | create(); 22 | \assert($throttledNotification instanceof ThrottledNotification); 23 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 24 | $callable = new CallableFake(); 25 | 26 | // act 27 | $this->notifiables()->each($callable); 28 | 29 | // assert 30 | $callable->assertCalledTimes(static function (Notifiable $notifiable) use ($throttledNotification): bool { 31 | return $notifiable->is($throttledNotification->databaseNotification->notifiable); 32 | }, 1); 33 | } 34 | 35 | public function testNotificationsBeforeWaitTimeHasLaspedAreIgnored(): void 36 | { 37 | // arrange 38 | Carbon::setTestNow(Carbon::now()); 39 | \factory(ThrottledNotification::class)->create(); 40 | Carbon::setTestNow(Carbon::now()->addMinutes(10)->subSecond()); 41 | $callable = new CallableFake(); 42 | 43 | // act 44 | $this->notifiables()->each($callable); 45 | 46 | // assert 47 | $callable->assertNotInvoked(); 48 | } 49 | 50 | public function testOnlyIncludesOnePerNotifiable(): void 51 | { 52 | // arrange 53 | Carbon::setTestNow(Carbon::now()); 54 | $notifiable = \factory(Notifiable::class)->create(); 55 | \assert($notifiable instanceof Notifiable); 56 | $scenario = static function () use ($notifiable): void { 57 | $notification = \factory(DatabaseNotification::class)->create([ 58 | 'notifiable_id' => $notifiable->id, 59 | ]); 60 | \assert($notification instanceof DatabaseNotification); 61 | \factory(ThrottledNotification::class)->create([ 62 | 'notification_id' => $notification->id, 63 | ]); 64 | }; 65 | $scenario(); 66 | $scenario(); 67 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 68 | $callable = new CallableFake(); 69 | 70 | // act 71 | $this->notifiables()->each($callable); 72 | 73 | // assert 74 | $callable->assertCalledTimes(static function (Notifiable $received) use ($notifiable): bool { 75 | return $received->is($notifiable); 76 | }, 1); 77 | } 78 | 79 | public function testIncludesMultipleNotifiable(): void 80 | { 81 | // arrange 82 | Carbon::setTestNow(Carbon::now()); 83 | $scenario = static function (): DatabaseNotification { 84 | $notification = \factory(DatabaseNotification::class)->create(); 85 | \assert($notification instanceof DatabaseNotification); 86 | \factory(ThrottledNotification::class)->create([ 87 | 'notification_id' => $notification->id, 88 | ]); 89 | 90 | return $notification; 91 | }; 92 | $first = $scenario(); 93 | $second = $scenario(); 94 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 95 | $callable = new CallableFake(); 96 | 97 | // act 98 | $this->notifiables()->each($callable); 99 | 100 | // assert 101 | $callable->assertTimesInvoked(2); 102 | $callable->assertCalled(static function (Notifiable $notifiable) use ($first): bool { 103 | return $notifiable->is($first->notifiable); 104 | }); 105 | $callable->assertCalled(static function (Notifiable $notifiable) use ($second): bool { 106 | return $notifiable->is($second->notifiable); 107 | }); 108 | } 109 | 110 | public function testReadNotificationAreIgnored(): void 111 | { 112 | // arrange 113 | Carbon::setTestNow(Carbon::now()); 114 | $databaseNotification = \factory(DatabaseNotification::class)->states(['read'])->create(); 115 | \assert($databaseNotification instanceof DatabaseNotification); 116 | \factory(ThrottledNotification::class)->create([ 117 | 'notification_id' => $databaseNotification->id, 118 | ]); 119 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 120 | $callable = new CallableFake(); 121 | 122 | // act 123 | $this->notifiables()->each($callable); 124 | 125 | // assert 126 | $callable->assertNotInvoked(); 127 | } 128 | 129 | public function testSentNotificationsAreIgnored(): void 130 | { 131 | // arrange 132 | Carbon::setTestNow(Carbon::now()); 133 | \factory(ThrottledNotification::class)->states(['sent'])->create(); 134 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 135 | $callable = new CallableFake(); 136 | 137 | // act 138 | $this->notifiables()->each($callable); 139 | 140 | // assert 141 | $callable->assertNotInvoked(); 142 | } 143 | 144 | public function testReservedNotificationsAreIgnored(): void 145 | { 146 | // arrange 147 | Carbon::setTestNow(Carbon::now()); 148 | \factory(ThrottledNotification::class)->states(['reserved'])->create(); 149 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 150 | $callable = new CallableFake(); 151 | 152 | // act 153 | $this->notifiables()->each($callable); 154 | 155 | // assert 156 | $callable->assertNotInvoked(); 157 | } 158 | 159 | private function notifiables(): Notifiables 160 | { 161 | $notifiables = $this->app[Notifiables::class]; 162 | 163 | \assert($notifiables instanceof Notifiables); 164 | 165 | return $notifiables; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/Feature/ReservablesTest.php: -------------------------------------------------------------------------------- 1 | create(); 19 | \assert($notification instanceof ThrottledNotification); 20 | 21 | // guard assert 22 | $this->assertNull($notification->reserved_key); 23 | 24 | // act 25 | $count = $this->reservables()->reserve($notification->databaseNotification->notifiable, 'xxxx'); 26 | 27 | // assert 28 | $notification = $notification->refresh(); 29 | $this->assertSame(1, $count); 30 | $this->assertSame('xxxx', $notification->reserved_key); 31 | } 32 | 33 | public function testNotificationsForOtherNotifiablesAreIgnored(): void 34 | { 35 | // arrange 36 | $notification = \factory(ThrottledNotification::class)->create(); 37 | \assert($notification instanceof ThrottledNotification); 38 | $notifiable = \factory(Notifiable::class)->create(); 39 | \assert($notifiable instanceof Notifiable); 40 | 41 | // guard assert 42 | $this->assertNull($notification->reserved_key); 43 | 44 | // act 45 | $count = $this->reservables()->reserve($notifiable, 'xxxx'); 46 | 47 | // assert 48 | $notification = $notification->refresh(); 49 | $this->assertSame(0, $count); 50 | $this->assertNull($notification->reserved_key); 51 | } 52 | 53 | public function testDelayedNotificationsAreIgnored(): void 54 | { 55 | // arrange 56 | $notification = \factory(ThrottledNotification::class)->states(['delayed'])->create(); 57 | \assert($notification instanceof ThrottledNotification); 58 | 59 | // guard assert 60 | $this->assertNull($notification->reserved_key); 61 | 62 | // act 63 | $count = $this->reservables()->reserve($notification->databaseNotification->notifiable, 'xxxx'); 64 | 65 | // assert 66 | $notification = $notification->refresh(); 67 | $this->assertSame(0, $count); 68 | $this->assertNull($notification->reserved_key); 69 | } 70 | 71 | public function testSentNotificationsAreIgnored(): void 72 | { 73 | // arrange 74 | $notification = \factory(ThrottledNotification::class)->states(['sent'])->create(); 75 | \assert($notification instanceof ThrottledNotification); 76 | 77 | // guard assert 78 | $this->assertNull($notification->reserved_key); 79 | 80 | // act 81 | $count = $this->reservables()->reserve($notification->databaseNotification->notifiable, 'xxxx'); 82 | 83 | // assert 84 | $notification = $notification->refresh(); 85 | $this->assertSame(0, $count); 86 | $this->assertNull($notification->reserved_key); 87 | } 88 | 89 | public function testReadNotificationAreIgnored(): void 90 | { 91 | // arrange 92 | $databaseNotification = \factory(DatabaseNotification::class)->states(['read'])->create(); 93 | \assert($databaseNotification instanceof DatabaseNotification); 94 | $throttledNotification = \factory(ThrottledNotification::class)->create([ 95 | 'notification_id' => $databaseNotification->id, 96 | ]); 97 | \assert($throttledNotification instanceof ThrottledNotification); 98 | 99 | // guard assert 100 | $this->assertNull($throttledNotification->reserved_key); 101 | 102 | // act 103 | $count = $this->reservables()->reserve($databaseNotification->notifiable, 'xxxx'); 104 | 105 | // assert 106 | $throttledNotification = $throttledNotification->refresh(); 107 | $this->assertSame(0, $count); 108 | $this->assertNull($throttledNotification->reserved_key); 109 | } 110 | 111 | public function testReservedNotificationsAreIgnored(): void 112 | { 113 | // arrange 114 | $notification = \factory(ThrottledNotification::class)->states(['reserved'])->create(); 115 | \assert($notification instanceof ThrottledNotification); 116 | 117 | // guard assert 118 | $this->assertNotNull($notification->reserved_key); 119 | 120 | // act 121 | $count = $this->reservables()->reserve($notification->databaseNotification->notifiable, 'xxxx'); 122 | 123 | // assert 124 | $notification = $notification->refresh(); 125 | $this->assertSame(0, $count); 126 | $this->assertNotNull($notification->reserved_key); 127 | $this->assertNotSame('xxxx', $notification->reserved_key); 128 | } 129 | 130 | public function testNotificationsWithDifferentKeyAreNotReleased(): void 131 | { 132 | // arrange 133 | $notification = \factory(ThrottledNotification::class)->states(['reserved'])->create(); 134 | \assert($notification instanceof ThrottledNotification); 135 | 136 | // guard assert 137 | $this->assertNotNull($notification->reserved_key); 138 | 139 | // act 140 | $count = $this->reservables()->release('xxxx'); 141 | 142 | // assert 143 | $notification = $notification->refresh(); 144 | $this->assertSame(0, $count); 145 | $this->assertNotNull($notification->reserved_key); 146 | $this->assertNotSame('xxxx', $notification->reserved_key); 147 | } 148 | 149 | public function testNotificationsAreReleased(): void 150 | { 151 | // arrange 152 | $notification = \factory(ThrottledNotification::class)->states(['reserved'])->create(); 153 | \assert($notification instanceof ThrottledNotification); 154 | 155 | // guard assertion 156 | $this->assertNotNull($notification->reserved_key); 157 | 158 | // act 159 | $count = $this->reservables()->release($notification->reserved_key); 160 | 161 | // assert 162 | $notification = $notification->refresh(); 163 | $this->assertSame(1, $count); 164 | $this->assertNull($notification->reserved_key); 165 | } 166 | 167 | public function testReservedNotificationsAreRetrieved(): void 168 | { 169 | // arrange 170 | $notification = \factory(ThrottledNotification::class)->states(['reserved'])->create(); 171 | \assert($notification instanceof ThrottledNotification); 172 | 173 | // guard assertion 174 | $this->assertIsString($notification->reserved_key); 175 | 176 | // act 177 | $notifications = $this->reservables()->get($notification->reserved_key); 178 | 179 | // assert 180 | $this->assertCount(1, $notifications); 181 | } 182 | 183 | public function testReservedNotificationWithAnotherKeyAreNotRetrieved(): void 184 | { 185 | // arrange 186 | \factory(ThrottledNotification::class)->states(['reserved'])->create(); 187 | 188 | // act 189 | $notifications = $this->reservables()->get('xxxx'); 190 | 191 | // assert 192 | $this->assertCount(0, $notifications); 193 | } 194 | 195 | private function reservables(): Reservables 196 | { 197 | $reservables = $this->app[Reservables::class]; 198 | 199 | \assert($reservables instanceof Reservables); 200 | 201 | return $reservables; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/Feature/SendThrottledNotificationsTest.php: -------------------------------------------------------------------------------- 1 | create(); 22 | \assert($throttledNotification instanceof ThrottledNotification); 23 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 24 | 25 | // act 26 | $this->app->call([new SendThrottledNotifications(), 'handle']); 27 | 28 | // assert 29 | /** @var \TiMacDonald\ThrottledNotifications\Jobs\SendThrottledNotificationsToNotifiable[] */ 30 | $jobs = Bus::dispatched(SendThrottledNotificationsToNotifiable::class); 31 | $this->assertCount(1, $jobs); 32 | $this->assertTrue($throttledNotification->databaseNotification->notifiable->is($jobs[0]->notifiable())); 33 | } 34 | 35 | public function testDifferentUuidProvidedToEachDispatchedJob(): void 36 | { 37 | // arrange 38 | Bus::fake(); 39 | $first = \factory(ThrottledNotification::class)->create(); 40 | \assert($first instanceof ThrottledNotification); 41 | $second = \factory(ThrottledNotification::class)->create(); 42 | \assert($second instanceof ThrottledNotification); 43 | Carbon::setTestNow(Carbon::now()->addMinutes(10)); 44 | 45 | // act 46 | $this->app->call([new SendThrottledNotifications(), 'handle']); 47 | 48 | // assert 49 | /** @var \TiMacDonald\ThrottledNotifications\Jobs\SendThrottledNotificationsToNotifiable[] */ 50 | $jobs = Bus::dispatched(SendThrottledNotificationsToNotifiable::class); 51 | $this->assertCount(2, $jobs); 52 | $this->assertTrue(Str::isUuid($jobs[0]->key())); 53 | $this->assertTrue(Str::isUuid($jobs[1]->key())); 54 | $this->assertNotSame($jobs[0]->key(), $jobs[1]->key()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Feature/SendThrottledNotificationsToNotifiableTest.php: -------------------------------------------------------------------------------- 1 | app->bind(Courier::class, CourierFake::class); 21 | $notification = \factory(ThrottledNotification::class)->create(); 22 | \assert($notification instanceof ThrottledNotification); 23 | 24 | // act 25 | $job = new SendThrottledNotificationsToNotifiable($notification->databaseNotification->notifiable, 'expected-key'); 26 | $this->app->call([$job, 'handle']); 27 | 28 | // assert 29 | $notification->refresh(); 30 | $this->assertSame('expected-key', $notification->reserved_key); 31 | } 32 | 33 | public function testWhenNotificationsAreReleasedWhenJobFails(): void 34 | { 35 | // arrange 36 | $this->app->bind(Courier::class, CourierFake::class); 37 | $notification = \factory(ThrottledNotification::class)->states(['reserved'])->create([ 38 | 'reserved_key' => 'reserved-key', 39 | ]); 40 | \assert($notification instanceof ThrottledNotification); 41 | 42 | // act 43 | $job = new SendThrottledNotificationsToNotifiable($notification->databaseNotification->notifiable, 'reserved-key'); 44 | $job->failed(new Exception()); 45 | 46 | // assert 47 | $notification->refresh(); 48 | $this->assertNull($notification->reserved_key); 49 | } 50 | 51 | public function testNotificationsAreMarkedAsSent(): void 52 | { 53 | // arrange 54 | $this->app->bind(Courier::class, CourierFake::class); 55 | $notification = \factory(ThrottledNotification::class)->create(); 56 | \assert($notification instanceof ThrottledNotification); 57 | 58 | // guard assert 59 | $this->assertNull($notification->sent_at); 60 | 61 | // act 62 | $job = new SendThrottledNotificationsToNotifiable($notification->databaseNotification->notifiable, 'xxxx'); 63 | $this->app->call([$job, 'handle']); 64 | 65 | // assert 66 | $notification = $notification->refresh(); 67 | $this->assertNotNull($notification->sent_at); 68 | } 69 | 70 | public function testBailsWhenNotReservablesAreFound(): void 71 | { 72 | // arrange 73 | $courier = new CourierFake(); 74 | $this->app->instance(Courier::class, $courier); 75 | $notifiable = \factory(Notifiable::class)->create(); 76 | \assert($notifiable instanceof Notifiable); 77 | 78 | // act 79 | $job = new SendThrottledNotificationsToNotifiable($notifiable, 'xxxx'); 80 | $this->app->call([$job, 'handle']); 81 | 82 | // assert 83 | $courier->assertNothingSent(); 84 | } 85 | 86 | public function testCourierSendsEmailToNotifiable(): void 87 | { 88 | // arrange 89 | $courier = new CourierFake(); 90 | $this->app->instance(Courier::class, $courier); 91 | $this->markTestIncomplete(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Feature/ThrottleChannelTest.php: -------------------------------------------------------------------------------- 1 | create(); 20 | \assert($notifiable instanceof Notifiable); 21 | $notification = new Notification(); 22 | 23 | // act 24 | $this->channelManager()->send($notifiable, $notification); 25 | 26 | // assert 27 | $this->assertSame(1, DatabaseNotification::query()->count()); 28 | } 29 | 30 | public function testThrottledNotificationIsCreated(): void 31 | { 32 | // arrange 33 | $notifiable = \factory(Notifiable::class)->create(); 34 | \assert($notifiable instanceof Notifiable); 35 | $notification = new Notification(); 36 | 37 | // act 38 | $this->channelManager()->send($notifiable, $notification); 39 | 40 | // assert 41 | $this->assertSame(1, ThrottledNotification::query()->count()); 42 | } 43 | 44 | private function channelManager(): ChannelManager 45 | { 46 | $channelManager = $this->app[ChannelManager::class]; 47 | 48 | \assert($channelManager instanceof ChannelManager); 49 | 50 | return $channelManager; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Notifiable.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/migrations'); 21 | 22 | $this->withFactories(__DIR__.'/factories'); 23 | 24 | $this->artisan('migrate'); 25 | } 26 | 27 | protected function getPackageProviders($app): array 28 | { 29 | return [ 30 | ServiceProvider::class, 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Unit/DelayTest.php: -------------------------------------------------------------------------------- 1 | until($notifiable); 23 | 24 | // assert 25 | $this->assertNull($delay); 26 | } 27 | 28 | public function testDelayIsExpectedDateIfNotifiableImplementsDelayUntilMethodWithFutureDate(): void 29 | { 30 | // arrange 31 | Carbon::setTestNow(Carbon::now()); 32 | $notifiable = new class() extends Model { 33 | public function delayNotificationsUntil(): Carbon 34 | { 35 | return Carbon::now()->addDay(); 36 | } 37 | }; 38 | 39 | // act 40 | $delay = (new Delay())->until($notifiable); 41 | 42 | // assert 43 | $this->assertTrue(Carbon::now()->addDay()->eq($delay)); 44 | } 45 | 46 | public function testDelayIsNotIfNotifiableImplementsDelayUntilMethodWithNow(): void 47 | { 48 | // arrange 49 | Carbon::setTestNow(Carbon::now()); 50 | $notifiable = new class() extends Model { 51 | public function delayNotificationsUntil(): Carbon 52 | { 53 | return Carbon::now(); 54 | } 55 | }; 56 | 57 | // act 58 | $delay = (new Delay())->until($notifiable); 59 | 60 | // assert 61 | $this->assertNull($delay); 62 | } 63 | 64 | public function testDelayIsNullIfNotifiableImplementsDelayUntilMethodWithPastDate(): void 65 | { 66 | // arrange 67 | Carbon::setTestNow(Carbon::now()); 68 | $notifiable = new class() extends Model { 69 | public function delayNotificationsUntil(): Carbon 70 | { 71 | return Carbon::now()->subMinute(); 72 | } 73 | }; 74 | 75 | // act 76 | $delay = (new Delay())->until($notifiable); 77 | 78 | // assert 79 | $this->assertNull($delay); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Unit/ThrottledNotificationTest.php: -------------------------------------------------------------------------------- 1 | id = 'expected id'; 18 | $throttledNotification = new ThrottledNotification(); 19 | 20 | // act 21 | $throttledNotification->payload = $notification; 22 | 23 | // assert 24 | $this->assertNotSame($notification, $throttledNotification->payload); 25 | $this->assertSame('expected id', $throttledNotification->payload->id); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Unit/WaitTest.php: -------------------------------------------------------------------------------- 1 | subSeconds(54321); 19 | 20 | // act 21 | $date = $wait->lapsesAt(); 22 | 23 | // assert 24 | $this->assertTrue($expected->eq($date)); 25 | } 26 | 27 | public function testCanUseStaticConstructorToSetInMinutes(): void 28 | { 29 | // arrange 30 | Carbon::setTestNow(Carbon::now()); 31 | $expected = new Wait(54321 * 60); 32 | 33 | // act 34 | $wait = Wait::fromMinutes(54321); 35 | 36 | // assert 37 | $this->assertTrue($expected->lapsesAt()->eq($wait->lapsesAt())); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/factories/DatabaseNotificationFactory.php: -------------------------------------------------------------------------------- 1 | define(DatabaseNotification::class, static function (Faker $faker) { 12 | return [ 13 | 'id' => $faker->unique()->uuid, 14 | 'data' => '{}', 15 | 'notifiable_id' => \factory(Notifiable::class), 16 | 'notifiable_type' => Notifiable::class, 17 | 'type' => '', 18 | ]; 19 | }); 20 | 21 | $factory->state(DatabaseNotification::class, 'read', static function (Faker $faker) { 22 | return [ 23 | 'read_at' => $faker->dateTime, 24 | ]; 25 | }); 26 | -------------------------------------------------------------------------------- /tests/factories/NotifiableFactory.php: -------------------------------------------------------------------------------- 1 | define(Notifiable::class, static function () { 10 | return [ 11 | ]; 12 | }); 13 | -------------------------------------------------------------------------------- /tests/factories/ThrottledNotificationFactory.php: -------------------------------------------------------------------------------- 1 | define(ThrottledNotification::class, static function () { 13 | return [ 14 | 'notification_id' => \factory(DatabaseNotification::class), 15 | 'payload' => new Notification(), 16 | ]; 17 | }); 18 | 19 | $factory->state(ThrottledNotification::class, 'sent', static function (Faker $faker) { 20 | return [ 21 | 'sent_at' => $faker->dateTime, 22 | ]; 23 | }); 24 | 25 | $factory->state(ThrottledNotification::class, 'delayed', static function (Faker $faker) { 26 | return [ 27 | 'delayed_until' => $faker->dateTime, 28 | ]; 29 | }); 30 | 31 | $factory->state(ThrottledNotification::class, 'reserved', static function (Faker $faker) { 32 | return [ 33 | 'reserved_key' => $faker->uuid, 34 | ]; 35 | }); 36 | -------------------------------------------------------------------------------- /tests/migrations/2019_08_25_093633_create_notifiables_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 15 | $table->timestamps(); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::dropIfExists('notifiables'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/migrations/2019_08_25_093633_create_notifications_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 15 | $table->string('type'); 16 | $table->morphs('notifiable'); 17 | $table->text('data'); 18 | $table->timestamp('read_at')->nullable(); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | public function down(): void 24 | { 25 | Schema::dropIfExists('notifications'); 26 | } 27 | } 28 | --------------------------------------------------------------------------------