├── .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 | [](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 |
--------------------------------------------------------------------------------