From bec2f7e799525d5ee54f4f933bf215ca2828a0b8 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Wed, 7 Oct 2026 18:23:58 +0600 Subject: [PATCH 1/2] Notifier: fix duplicate and misdirected notifications, add mail and webhook channels, events, fake, and a delivery log --- phpstan-baseline.neon | 30 +++ src/Channels/Contracts/ChannelDriver.php | 16 +- src/Channels/DatabaseChannel.php | 18 +- src/Channels/DiscordChannel.php | 31 +-- src/Channels/MailChannel.php | 110 ++++++++ src/Channels/SlackChannel.php | 34 +-- src/Channels/Support/Http.php | 62 +++++ src/Channels/Support/MailTemplate.php | 106 ++++++++ src/Channels/WebhookChannel.php | 67 +++++ src/Concerns/Notifiable.php | 144 ++++++++-- src/Concerns/NotificationPipeline.php | 62 +++-- .../Commands/PruneNotificationsCommand.php | 45 ++++ src/Contracts/Notification.php | 83 ++++++ src/Models/DatabaseNotification.php | 40 +++ src/Models/NotificationDelivery.php | 55 ++++ src/NotificationDispatcher.php | 191 ++++++++++--- src/NotificationEvents.php | 116 ++++++++ src/NotificationManager.php | 20 +- src/NotifierLauncher.php | 4 +- src/Support/DeliveryLog.php | 170 ++++++++++++ src/Support/Id.php | 20 ++ src/Support/Notifiables.php | 79 ++++++ src/Supports/Facades/Notification.php | 68 +++++ src/Testing/NotificationFake.php | 254 ++++++++++++++++++ ...0_create_notification_deliveries_table.php | 41 +++ tests/Mock/MockContainer.php | 11 + tests/Mock/Models/TestUser.php | 40 +++ .../ConfigurableNotification.php | 86 ++++++ tests/Mock/SpyPruneCommand.php | 51 ++++ tests/Support/NotifierTestCase.php | 250 +++++++++++++++++ tests/Unit/BuildersTest.php | 124 +++++++++ tests/Unit/ChannelsTest.php | 164 +++++++++++ tests/Unit/ContractTest.php | 124 +++++++++ tests/Unit/DispatcherTest.php | 230 ++++++++++++++++ tests/Unit/EventsTest.php | 162 +++++++++++ tests/Unit/FakeTest.php | 203 ++++++++++++++ tests/Unit/MailChannelTest.php | 117 ++++++++ tests/Unit/MigrationTest.php | 35 +++ tests/Unit/NotifiableTest.php | 156 +++++++++++ tests/Unit/PipelineTest.php | 164 +++++++++++ tests/Unit/PruneCommandTest.php | 53 ++++ tests/Unit/QueueFeaturesTest.php | 110 ++++++++ tests/Unit/RedisQueueTest.php | 137 ++++++++++ 43 files changed, 3953 insertions(+), 130 deletions(-) create mode 100644 src/Channels/MailChannel.php create mode 100644 src/Channels/Support/Http.php create mode 100644 src/Channels/Support/MailTemplate.php create mode 100644 src/Channels/WebhookChannel.php create mode 100644 src/Console/Commands/PruneNotificationsCommand.php create mode 100644 src/Models/NotificationDelivery.php create mode 100644 src/NotificationEvents.php create mode 100644 src/Support/DeliveryLog.php create mode 100644 src/Support/Id.php create mode 100644 src/Support/Notifiables.php create mode 100644 src/Testing/NotificationFake.php create mode 100644 src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php create mode 100644 tests/Mock/Models/TestUser.php create mode 100644 tests/Mock/Notifications/ConfigurableNotification.php create mode 100644 tests/Mock/SpyPruneCommand.php create mode 100644 tests/Support/NotifierTestCase.php create mode 100644 tests/Unit/BuildersTest.php create mode 100644 tests/Unit/ChannelsTest.php create mode 100644 tests/Unit/ContractTest.php create mode 100644 tests/Unit/DispatcherTest.php create mode 100644 tests/Unit/EventsTest.php create mode 100644 tests/Unit/FakeTest.php create mode 100644 tests/Unit/MailChannelTest.php create mode 100644 tests/Unit/MigrationTest.php create mode 100644 tests/Unit/NotifiableTest.php create mode 100644 tests/Unit/PipelineTest.php create mode 100644 tests/Unit/PruneCommandTest.php create mode 100644 tests/Unit/QueueFeaturesTest.php create mode 100644 tests/Unit/RedisQueueTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 453271f..e8d165b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -269,3 +269,33 @@ parameters: identifier: staticMethod.notFound count: 1 path: src/database/migrations/2025_04_19_184307_create_notifications_table.php + + - + message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Mail\:\:to\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/Channels/MailChannel.php + + - + message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:error\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/NotificationEvents.php + + - + message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:warning\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/Support/DeliveryLog.php + + - + message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Schema\:\:create\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php + + - + message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Schema\:\:dropIfExists\(\)\.$#' + identifier: staticMethod.notFound + count: 1 + path: src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php diff --git a/src/Channels/Contracts/ChannelDriver.php b/src/Channels/Contracts/ChannelDriver.php index 9b141af..2b6fc0e 100644 --- a/src/Channels/Contracts/ChannelDriver.php +++ b/src/Channels/Contracts/ChannelDriver.php @@ -16,12 +16,14 @@ abstract class ChannelDriver /** * Create a new channel driver instance - * + * * @param mixed $app */ public function __construct($app = null) { - $this->app = $app; + if ($app instanceof Application) { + $this->app = $app; + } } /** @@ -43,10 +45,14 @@ abstract public function send($notifiable, Notification $notification): void; */ protected function config(string $key, $default = null) { - if ($this->app && function_exists('config')) { - return config($key, $default); + if (function_exists('config')) { + try { + return config($key, $default); + } catch (\Throwable) { + return $default; + } } return $default; } -} \ No newline at end of file +} diff --git a/src/Channels/DatabaseChannel.php b/src/Channels/DatabaseChannel.php index af0230d..25cc5d2 100644 --- a/src/Channels/DatabaseChannel.php +++ b/src/Channels/DatabaseChannel.php @@ -2,21 +2,31 @@ namespace Doppar\Notifier\Channels; +use Doppar\Notifier\Models\DatabaseNotification; use Doppar\Notifier\Contracts\Notification; use Doppar\Notifier\Channels\Contracts\ChannelDriver; +use Doppar\Notifier\Support\Notifiables; class DatabaseChannel extends ChannelDriver { /** - * Send the notification through the database channel. + * Store the notification so the notifiable can read it in the application * * @param mixed $notifiable * @param Notification $notification * @return void + * @throws \RuntimeException */ public function send($notifiable, Notification $notification): void { - // Database storage is handled by NotificationDispatcher - // This channel just marks that database storage is needed + DatabaseNotification::create([ + 'notifiable_type' => Notifiables::type($notifiable), + 'notifiable_id' => Notifiables::requireKey($notifiable), + 'type' => get_class($notification), + 'data' => json_encode($notification->contentFor('database', $notifiable), JSON_THROW_ON_ERROR), + 'metadata' => json_encode($notification->metadata(), JSON_THROW_ON_ERROR), + 'read_at' => null, + 'created_at' => date('Y-m-d H:i:s'), + ]); } -} \ No newline at end of file +} diff --git a/src/Channels/DiscordChannel.php b/src/Channels/DiscordChannel.php index a3a8a56..a74ef26 100644 --- a/src/Channels/DiscordChannel.php +++ b/src/Channels/DiscordChannel.php @@ -3,13 +3,14 @@ namespace Doppar\Notifier\Channels; use Doppar\Notifier\Contracts\Notification; +use Doppar\Notifier\Channels\Support\Http; use Doppar\Notifier\Channels\Contracts\ChannelDriver; class DiscordChannel extends ChannelDriver { /** * Send notification to Discord - * + * * @param mixed $notifiable * @param Notification $notification * @return void @@ -23,16 +24,16 @@ public function send($notifiable, Notification $notification): void throw new \RuntimeException('No Discord webhook URL defined for notifiable entity.'); } - $content = $notification->content($notifiable); + $content = $notification->contentFor('discord', $notifiable); $payload = [ 'content' => $content['content'] ?? '', - 'username' => $content['username'] ?? config('notification.discord.username', 'Doppar Bot'), + 'username' => $content['username'] ?? $this->config('notification.discord.username', 'Doppar Bot'), 'avatar_url' => $content['avatar'] ?? null, 'embeds' => $content['embeds'] ?? [], ]; - $this->postToDiscord($webhookUrl, $payload); + $this->postToDiscord((string) $webhookUrl, $payload); } /** @@ -45,18 +46,14 @@ public function send($notifiable, Notification $notification): void */ protected function postToDiscord(string $webhookUrl, array $payload): void { - $ch = curl_init($webhookUrl); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - $result = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode < 200 || $httpCode >= 300) { - throw new \RuntimeException("Discord notification failed with HTTP code: {$httpCode}"); + $response = Http::post( + $webhookUrl, + json_encode($payload, JSON_THROW_ON_ERROR), + ['Content-Type: application/json'] + ); + + if ($response['status'] < 200 || $response['status'] >= 300) { + throw new \RuntimeException("Discord notification failed with HTTP code: {$response['status']}"); } } -} \ No newline at end of file +} diff --git a/src/Channels/MailChannel.php b/src/Channels/MailChannel.php new file mode 100644 index 0000000..836e1d1 --- /dev/null +++ b/src/Channels/MailChannel.php @@ -0,0 +1,110 @@ +): void)|null + */ + protected static $sender = null; + + /** + * Replace how mail is handed over, or restore the application mailer with null + * + * @param callable|null $sender + * @return void + */ + public static function using(?callable $sender): void + { + static::$sender = $sender; + } + + /** + * Send the notification as an email + * + * @param mixed $notifiable + * @param Notification $notification + * @return void + * @throws \RuntimeException + */ + public function send($notifiable, Notification $notification): void + { + $address = $notifiable->routeNotificationFor('mail'); + + if (!is_string($address) || !filter_var($address, FILTER_VALIDATE_EMAIL)) { + throw new \RuntimeException('Mail address is missing or invalid.'); + } + + $content = $notification->contentFor('mail', $notifiable); + $mailable = $this->buildMailable($content); + $name = Notifiables::attribute($notifiable, 'name'); + $name = is_string($name) && $name !== '' ? $name : null; + + if (static::$sender !== null) { + (static::$sender)($address, $name, $mailable, $content); + + return; + } + + $mail = Mail::to($address, $name); + + foreach (['cc', 'bcc'] as $field) { + if (!empty($content[$field])) { + $mail->{$field}($content[$field]); + } + } + + $mail->send($mailable); + } + + /** + * Build the Mailable for the mail content + * + * @param array $content + * @return Mailable + */ + protected function buildMailable(array $content): Mailable + { + $mailable = new Mailable(); + $mailable->subject = (string) ($content['subject'] ?? 'Notification'); + + $rendered = isset($content['html']) ? null : MailTemplate::render($content); + $mailable->html((string) ($content['html'] ?? $rendered['html'] ?? '')); + + $text = $content['text'] ?? $rendered['text'] ?? null; + + if (is_string($text) && $text !== '') { + $mailable->text($text); + } + + if (!empty($content['replyTo'])) { + $mailable->replyTo($content['replyTo']); + } + + foreach ((array) ($content['tags'] ?? []) as $tag) { + $mailable->tag((string) $tag); + } + + if (isset($content['priority'])) { + $mailable->priority((int) $content['priority']); + } + + foreach ((array) ($content['attachments'] ?? []) as $attachment) { + $attachment = is_array($attachment) ? $attachment : ['path' => $attachment]; + + $mailable->attach((string) $attachment['path'], $attachment['name'] ?? null, $attachment['mime'] ?? null); + } + + return $mailable; + } +} diff --git a/src/Channels/SlackChannel.php b/src/Channels/SlackChannel.php index 3487fe2..6153727 100644 --- a/src/Channels/SlackChannel.php +++ b/src/Channels/SlackChannel.php @@ -2,15 +2,15 @@ namespace Doppar\Notifier\Channels; -use Phaseolies\Support\Facades\Log; use Doppar\Notifier\Contracts\Notification; +use Doppar\Notifier\Channels\Support\Http; use Doppar\Notifier\Channels\Contracts\ChannelDriver; class SlackChannel extends ChannelDriver { /** * Send notification to Slack - * + * * @param mixed $notifiable * @param Notification $notification * @return void @@ -24,7 +24,7 @@ public function send($notifiable, Notification $notification): void throw new \RuntimeException('Slack webhook URL is missing.'); } - $content = $notification->content($notifiable); + $content = $notification->contentFor('slack', $notifiable); $payload = [ 'text' => $content['text'] ?? '', @@ -44,7 +44,7 @@ public function send($notifiable, Notification $notification): void $payload['channel'] = $content['channel']; } - $this->postToSlack($webhookUrl, $payload); + $this->postToSlack((string) $webhookUrl, $payload); } /** @@ -57,24 +57,16 @@ public function send($notifiable, Notification $notification): void */ protected function postToSlack(string $webhookUrl, array $payload): void { - $ch = curl_init($webhookUrl); - - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($payload), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_TIMEOUT => 10, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - - curl_close($ch); + $response = Http::post( + $webhookUrl, + json_encode($payload, JSON_THROW_ON_ERROR), + ['Content-Type: application/json'] + ); - if ($httpCode !== 200 || trim($response) !== 'ok') { - Log::error("Slack notification failed (HTTP {$httpCode}): " . ($response ?: $error)); + if ($response['status'] !== 200 || trim($response['body']) !== 'ok') { + throw new \RuntimeException( + "Slack notification failed (HTTP {$response['status']}): " . ($response['body'] ?: $response['error']) + ); } } } diff --git a/src/Channels/Support/Http.php b/src/Channels/Support/Http.php new file mode 100644 index 0000000..3dbf553 --- /dev/null +++ b/src/Channels/Support/Http.php @@ -0,0 +1,62 @@ +, int): array{status: int, body: string, error: string})|null + */ + private static $transport = null; + + /** + * Replace the HTTP transport, or restore cURL with null + * + * @param callable|null $transport + * @return void + */ + public static function fake(?callable $transport): void + { + self::$transport = $transport; + } + + /** + * Send a POST request + * + * @param string $url + * @param string $body + * @param array $headers + * @param int $timeout + * @return array{status: int, body: string, error: string} + */ + public static function post(string $url, string $body, array $headers = [], int $timeout = 10): array + { + if (self::$transport !== null) { + return (self::$transport)($url, $body, $headers, $timeout); + } + + $ch = curl_init($url); + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + ]); + + $response = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + + return [ + 'status' => $status, + 'body' => is_string($response) ? $response : '', + 'error' => $error, + ]; + } +} diff --git a/src/Channels/Support/MailTemplate.php b/src/Channels/Support/MailTemplate.php new file mode 100644 index 0000000..a01b8c0 --- /dev/null +++ b/src/Channels/Support/MailTemplate.php @@ -0,0 +1,106 @@ + $content + * @return array{html: string, text: string} + * @throws \InvalidArgumentException + */ + public static function render(array $content): array + { + $greeting = isset($content['greeting']) ? (string) $content['greeting'] : ''; + $lines = self::strings($content['lines'] ?? []); + $outro = self::strings($content['outro'] ?? []); + $footer = isset($content['footer']) ? (string) $content['footer'] : ''; + $action = self::action($content['action'] ?? null); + + $html = '
'; + + if ($greeting !== '') { + $html .= '

' . self::e($greeting) . '

'; + } + + foreach ($lines as $line) { + $html .= '

' . self::e($line) . '

'; + } + + if ($action !== null) { + $html .= '

' + . self::e($action['text']) . '

'; + } + + foreach ($outro as $line) { + $html .= '

' . self::e($line) . '

'; + } + + if ($footer !== '') { + $html .= '

' . self::e($footer) . '

'; + } + + $html .= '
'; + + $text = implode("\n\n", array_filter([ + $greeting, + implode("\n\n", $lines), + $action !== null ? $action['text'] . ': ' . $action['url'] : '', + implode("\n\n", $outro), + $footer, + ], static fn (string $part): bool => $part !== '')); + + return ['html' => $html, 'text' => $text]; + } + + /** + * Escape a value for HTML + * + * @param string $value + * @return string + */ + private static function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Get a list of non-empty strings + * + * @param mixed $value + * @return array + */ + private static function strings(mixed $value): array + { + $items = is_array($value) ? $value : [$value]; + + return array_values(array_filter( + array_map(static fn (mixed $item): string => is_scalar($item) ? (string) $item : '', $items), + static fn (string $item): bool => $item !== '' + )); + } + + /** + * Validate the action button + * + * @param mixed $action + * @return array{text: string, url: string}|null + * @throws \InvalidArgumentException + */ + private static function action(mixed $action): ?array + { + if (!is_array($action) || empty($action['url'])) { + return null; + } + + $url = (string) $action['url']; + + if (!preg_match('#^https?://#i', $url) || preg_match('/[\s<>"\']/', $url)) { + throw new \InvalidArgumentException('The mail action link must be a plain http(s) address.'); + } + + return ['text' => (string) ($action['text'] ?? 'Open'), 'url' => $url]; + } +} diff --git a/src/Channels/WebhookChannel.php b/src/Channels/WebhookChannel.php new file mode 100644 index 0000000..20eea22 --- /dev/null +++ b/src/Channels/WebhookChannel.php @@ -0,0 +1,67 @@ +, so the receiver can check who sent it. + * + * @param mixed $notifiable + * @param Notification $notification + * @return void + * @throws \RuntimeException + */ + public function send($notifiable, Notification $notification): void + { + $url = $notifiable->routeNotificationFor('webhook'); + + if (!is_string($url) || !preg_match('#^https?://#i', $url)) { + throw new \RuntimeException('Webhook URL is missing or is not an http(s) address.'); + } + + $content = $notification->contentFor('webhook', $notifiable); + $body = json_encode( + isset($content['payload']) && is_array($content['payload']) ? $content['payload'] : $content, + JSON_THROW_ON_ERROR + ); + + $headers = ['Content-Type: application/json']; + $custom = isset($content['headers']) && is_array($content['headers']) ? $content['headers'] : []; + + foreach ($custom as $name => $value) { + $name = (string) $name; + $line = $name . ': ' . (string) $value; + + if (preg_match('/[\r\n]/', $line) || strcasecmp($name, 'Content-Type') === 0) { + continue; + } + + $headers[] = $line; + } + + $secret = $content['secret'] ?? $this->config('notification.webhook.secret'); + + if (is_string($secret) && $secret !== '') { + $headers[] = 'X-Notification-Signature: sha256=' . hash_hmac('sha256', $body, $secret); + } + + $response = Http::post($url, $body, $headers); + + if ($response['status'] < 200 || $response['status'] >= 300) { + throw new \RuntimeException( + "Webhook notification failed (HTTP {$response['status']}): " . ($response['error'] ?: substr($response['body'], 0, 200)) + ); + } + } +} diff --git a/src/Concerns/Notifiable.php b/src/Concerns/Notifiable.php index 821f350..864af75 100644 --- a/src/Concerns/Notifiable.php +++ b/src/Concerns/Notifiable.php @@ -4,8 +4,10 @@ use Phaseolies\Support\Collection; use Doppar\Notifier\Models\DatabaseNotification; +use Doppar\Notifier\Models\NotificationDelivery; use Doppar\Notifier\Contracts\Notification; use Doppar\Notifier\Concerns\NotificationPipeline; +use Doppar\Notifier\Support\Notifiables; trait Notifiable { @@ -38,9 +40,7 @@ public function notifyNow(Notification $notification): void */ public function notifications(): Collection { - return DatabaseNotification::query() - ->where('notifiable_type', get_class($this)) - ->where('notifiable_id', auth()?->id()) + return $this->notificationsQuery() ->orderBy('id', 'DESC') ->get(); } @@ -52,9 +52,7 @@ public function notifications(): Collection */ public function unreadNotifications(): Collection { - return DatabaseNotification::query() - ->where('notifiable_type', get_class($this)) - ->where('notifiable_id', auth()?->id()) + return $this->notificationsQuery() ->whereNull('read_at') ->orderBy('id', 'DESC') ->get(); @@ -67,14 +65,24 @@ public function unreadNotifications(): Collection */ public function readNotifications(): Collection { - return DatabaseNotification::query() - ->where('notifiable_type', get_class($this)) - ->where('notifiable_id', auth()?->id()) + return $this->notificationsQuery() ->whereNotNull('read_at') ->orderBy('id', 'DESC') ->get(); } + /** + * Count the unread notifications + * + * @return int + */ + public function unreadNotificationsCount(): int + { + return (int) $this->notificationsQuery() + ->whereNull('read_at') + ->count(); + } + /** * Mark all notifications as read * @@ -82,13 +90,97 @@ public function readNotifications(): Collection */ public function markNotificationsAsRead(): int { - return DatabaseNotification::query() - ->where('notifiable_type', get_class($this)) - ->where('notifiable_id', auth()?->id()) - ->whereNull('read_at') - ->update([ - 'read_at' => now() - ]); + $unread = $this->unreadNotificationsCount(); + + if ($unread > 0) { + $this->notificationsQuery() + ->whereNull('read_at') + ->update([ + 'read_at' => date('Y-m-d H:i:s') + ]); + } + + return $unread; + } + + /** + * Mark one of this entity's notifications as read + * + * @param int|string $id + * @return bool + */ + public function markNotificationAsRead(int|string $id): bool + { + $notification = $this->notificationsQuery() + ->where('id', $id) + ->first(); + + return $notification !== null && $notification->markAsRead(); + } + + /** + * Delete one of this entity's notifications + * + * @param int|string $id + * @return bool + */ + public function deleteNotification(int|string $id): bool + { + if ((int) $this->notificationsQuery()->where('id', $id)->count() === 0) { + return false; + } + + $this->notificationsQuery() + ->where('id', $id) + ->delete(); + + return true; + } + + /** + * Delete the notifications this entity has read + * + * @return int + */ + public function deleteReadNotifications(): int + { + $read = (int) $this->notificationsQuery() + ->whereNotNull('read_at') + ->count(); + + if ($read > 0) { + $this->notificationsQuery() + ->whereNotNull('read_at') + ->delete(); + } + + return $read; + } + + /** + * Get what happened to the notifications sent to this entity, newest first + * + * @return \Phaseolies\Support\Collection + */ + public function notificationDeliveries(): Collection + { + return NotificationDelivery::query() + ->where('notifiable_type', Notifiables::type($this)) + ->where('notifiable_id', Notifiables::requireKey($this)) + ->orderBy('id', 'DESC') + ->get(); + } + + /** + * Decide if this entity wants a notification on a channel + * + * @param Notification $notification + * @param string $channel + * @return bool + */ + public function wantsNotification(Notification $notification, string $channel): bool + { + return true; } /** @@ -106,9 +198,25 @@ public function routeNotificationFor(string $channel): mixed } return match ($channel) { - 'slack' => $this->slack_webhook_url ?? null, - 'discord' => $this->discord_webhook_url ?? null, + 'mail' => Notifiables::attribute($this, 'email'), + 'slack' => Notifiables::attribute($this, 'slack_webhook_url'), + 'discord' => Notifiables::attribute($this, 'discord_webhook_url'), + 'webhook' => Notifiables::attribute($this, 'webhook_url'), default => null, }; } + + /** + * Start a query for this entity's stored notifications + * + * @return mixed + */ + protected function notificationsQuery(): mixed + { + $key = Notifiables::key($this); + + return DatabaseNotification::query() + ->where('notifiable_type', Notifiables::type($this)) + ->where('notifiable_id', $key ?? 0); + } } diff --git a/src/Concerns/NotificationPipeline.php b/src/Concerns/NotificationPipeline.php index 695fb87..8211939 100644 --- a/src/Concerns/NotificationPipeline.php +++ b/src/Concerns/NotificationPipeline.php @@ -4,6 +4,8 @@ use Doppar\Notifier\NotificationDispatcher; use Doppar\Notifier\Contracts\Notification; +use Doppar\Notifier\Support\Id; +use Doppar\Notifier\Testing\NotificationFake; class NotificationPipeline { @@ -99,7 +101,7 @@ public function immediate(): void /** * Send the notification - * + * * @return mixed */ public function send(): mixed @@ -108,38 +110,60 @@ public function send(): mixed return null; } - $channels = $this->channels ?? $this->notification->channels($this->notifiable); + $channels = $this->channels ?? $this->allowedChannels($this->notification->channels($this->notifiable)); if (empty($channels)) { return null; } + $channels = array_values(array_unique(array_map('strval', $channels))); $delay = $this->delay ?: $this->notification->deliveryDelay(); $this->sent = true; - if ($this->shouldQueue) { + $fake = NotificationFake::current(); + + if ($fake !== null) { + return $fake->record($this->notifiable, $this->notification, $channels, $delay, !$this->shouldQueue); + } + + $notificationId = Id::uuid(); + + if (!$this->shouldQueue) { + (new NotificationDispatcher($this->notifiable, $this->notification, $channels, $notificationId))->handle(); + + return null; + } + + foreach ($channels as $channel) { + $job = new NotificationDispatcher($this->notifiable, $this->notification, [$channel], $notificationId); + if ($delay > 0) { - return NotificationDispatcher::queueAfter( - $delay, - $this->notifiable, - $this->notification, - $channels - ); + $job->delayFor($delay)->forceQueue(); + } else { + $job->dispatch(); } + } + + return $notificationId; + } - return NotificationDispatcher::dispatchWith( - $this->notifiable, - $this->notification, - $channels - ); + /** + * Keep the channels the notifiable wants to receive this notification on + * + * @param array $channels + * @return array + */ + protected function allowedChannels(array $channels): array + { + if (!is_object($this->notifiable) || !method_exists($this->notifiable, 'wantsNotification')) { + return $channels; } - return NotificationDispatcher::queueAsSync( - $this->notifiable, - $this->notification, - $channels - ); + return array_values(array_filter( + $channels, + fn ($channel): bool => (bool) $this->notifiable->wantsNotification($this->notification, (string) $channel) + )); } /** diff --git a/src/Console/Commands/PruneNotificationsCommand.php b/src/Console/Commands/PruneNotificationsCommand.php new file mode 100644 index 0000000..0a2785e --- /dev/null +++ b/src/Console/Commands/PruneNotificationsCommand.php @@ -0,0 +1,45 @@ +option('days'); + + if (!is_numeric($days) || (int) $days < 0) { + $this->error('The --days option must be a number of days, zero or more.'); + + return Command::FAILURE; + } + + $count = DatabaseNotification::prune((int) $days, !$this->option('all')); + + $this->info("✔ {$count} notification(s) older than {$days} day(s) deleted."); + + return Command::SUCCESS; + } +} diff --git a/src/Contracts/Notification.php b/src/Contracts/Notification.php index 5ef2c4c..60427a1 100644 --- a/src/Contracts/Notification.php +++ b/src/Contracts/Notification.php @@ -24,6 +24,26 @@ abstract public function channels($notifiable): array; */ abstract public function content($notifiable): array; + /** + * Get the content for one channel + * + * @param string $channel + * @param mixed $notifiable + * @return array + */ + public function contentFor(string $channel, $notifiable): array + { + $method = 'to' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $channel))); + + if ($method !== 'to' && method_exists($this, $method)) { + $content = $this->{$method}($notifiable); + + return is_array($content) ? $content : []; + } + + return $this->content($notifiable); + } + /** * Get notification metadata * @@ -45,6 +65,18 @@ public function shouldSend($notifiable): bool return true; } + /** + * Check if notification should be sent through one channel + * + * @param mixed $notifiable + * @param string $channel + * @return bool + */ + public function shouldSendVia($notifiable, string $channel): bool + { + return true; + } + /** * Get the notification's delivery delay * @@ -54,4 +86,55 @@ public function deliveryDelay(): int { return 0; } + + /** + * Get the queue priority (-100 to 100, higher is delivered first) + * + * @return int + */ + public function priority(): int + { + return 0; + } + + /** + * Get the key that makes the notification unique for a notifiable + * + * @param mixed $notifiable + * @return string|null + */ + public function uniqueId($notifiable): ?string + { + return null; + } + + /** + * Get the seconds to wait before each retry + * + * @return int|array|null + */ + public function backoff(): int|array|null + { + return null; + } + + /** + * Get the queue connection the notification is queued on + * + * @return string|null + */ + public function onConnection(): ?string + { + return null; + } + + /** + * Get the limit on how often this notification reaches the same notifiable + * + * @return array{max: int, per: int}|null + */ + public function throttle(): ?array + { + return null; + } } diff --git a/src/Models/DatabaseNotification.php b/src/Models/DatabaseNotification.php index 8b4633f..1d910c7 100644 --- a/src/Models/DatabaseNotification.php +++ b/src/Models/DatabaseNotification.php @@ -4,6 +4,9 @@ use Phaseolies\Database\Entity\Model; +/** + * @property string $data + */ class DatabaseNotification extends Model { /** @@ -80,6 +83,43 @@ public function isUnread(): bool return $this->read_at === null; } + /** + * Get the notification's content as an array + * + * @return array + */ + public function payload(): array + { + $data = json_decode((string) $this->data, true); + + return is_array($data) ? $data : []; + } + + /** + * Delete old notifications + * + * @param int $days + * @param bool $onlyRead + * @return int + */ + public static function prune(int $days, bool $onlyRead = true): int + { + $cutoff = date('Y-m-d H:i:s', time() - max(0, $days) * 86400); + $matching = static function () use ($cutoff, $onlyRead) { + $query = static::query()->where('created_at', '<', $cutoff); + + return $onlyRead ? $query->whereNotNull('read_at') : $query; + }; + + $count = (int) $matching()->count(); + + if ($count > 0) { + $matching()->delete(); + } + + return $count; + } + /** * Get the notifiable entity * diff --git a/src/Models/NotificationDelivery.php b/src/Models/NotificationDelivery.php new file mode 100644 index 0000000..5e0658a --- /dev/null +++ b/src/Models/NotificationDelivery.php @@ -0,0 +1,55 @@ + + */ + protected $creatable = [ + 'delivery_key', + 'notification_id', + 'notification_type', + 'notifiable_type', + 'notifiable_id', + 'channel', + 'status', + 'attempts', + 'error', + 'sent_at', + 'created_at', + 'updated_at', + ]; + + /** + * Check if the delivery reached the channel + * + * @return bool + */ + public function wasSent(): bool + { + return $this->status === 'sent'; + } + + /** + * Check if the last attempt failed + * + * @return bool + */ + public function hasFailed(): bool + { + return $this->status === 'failed'; + } +} diff --git a/src/NotificationDispatcher.php b/src/NotificationDispatcher.php index 412efe8..fc28b01 100644 --- a/src/NotificationDispatcher.php +++ b/src/NotificationDispatcher.php @@ -7,8 +7,10 @@ use Doppar\Queue\InteractsWithModelSerialization; use Doppar\Queue\Dispatchable; use Doppar\Queue\Attributes\Queueable; -use Doppar\Notifier\Models\DatabaseNotification; use Doppar\Notifier\Contracts\Notification; +use Doppar\Notifier\Support\DeliveryLog; +use Doppar\Notifier\Support\Id; +use Doppar\Notifier\Support\Notifiables; #[Queueable(tries: 3, retryAfter: 60, onQueue: 'notifications')] class NotificationDispatcher extends Job @@ -21,78 +23,189 @@ class NotificationDispatcher extends Job * @param mixed $notifiable * @param Notification $notification * @param array $channels + * @param string|null $notificationId */ public function __construct( public mixed $notifiable, public Notification $notification, - public array $channels = [] - ) {} + public array $channels = [], + public ?string $notificationId = null + ) { + $this->notificationId ??= Id::uuid(); + } /** * Sends the notification across all specified channels * * @return void + * @throws \Throwable */ public function handle(): void { if (!$this->notification->shouldSend($this->notifiable)) { + foreach ($this->channels as $channel) { + $this->record((string) $channel, 'skipped'); + } + return; } - $manager = app(NotificationManager::class); - - if (in_array('database', $this->channels)) { - $this->storeNotification(); - } + $failure = null; foreach ($this->channels as $channel) { try { - if ($channel === 'database') { - continue; - } - - $driver = $manager->channel($channel); - $driver->send($this->notifiable, $this->notification); + $this->deliverVia((string) $channel); } catch (\Throwable $e) { - error("Notification failed on channel {$channel}: " . $e->getMessage()); - throw $e; + $failure ??= $e; } } + + if ($failure !== null) { + throw $failure; + } } /** - * Store the notification in the database + * Logs the exception message when dispatch fails * - * @return int|null + * @param \Throwable $exception + * @return void */ - protected function storeNotification(): ?int + public function failed(\Throwable $exception): void { - $notifiableType = get_class($this->notifiable); - $notifiableId = method_exists($this->notifiable, 'getKey') - ? $this->notifiable->getKey() - : $this->notifiable->id; - - $notification = DatabaseNotification::create([ - 'notifiable_type' => $notifiableType, - 'notifiable_id' => $notifiableId, - 'type' => get_class($this->notification), - 'data' => json_encode($this->notification->content($this->notifiable)), - 'metadata' => json_encode($this->notification->metadata()), - 'read_at' => null, - 'created_at' => date('Y-m-d H:i:s'), - ]); - - return $notification->id ?? null; + Log::error("Notification dispatch failed: " . $exception->getMessage()); } /** - * Logs the exception message when dispatch fails + * Get the queue priority of the notification * - * @param \Throwable $exception + * @return int + */ + public function priority(): int + { + return max(-100, min(100, $this->notification->priority())); + } + + /** + * Get the queue connection of the notification + * + * @return string|null + */ + public function connection(): ?string + { + return $this->notification->onConnection(); + } + + /** + * Get the seconds to wait before each retry + * + * @return int|array|null + */ + public function backoff(): int|array|null + { + return $this->notification->backoff(); + } + + /** + * Get the key that stops the same notification from being queued twice + * + * @return string|null + */ + public function uniqueId(): ?string + { + $id = $this->notification->uniqueId($this->notifiable); + + if ($id === null) { + return null; + } + + return 'notification:' . md5(implode('|', [ + get_class($this->notification), + Notifiables::type($this->notifiable), + (string) Notifiables::key($this->notifiable), + implode(',', $this->channels), + $id, + ])); + } + + /** + * Deliver the notification through one channel + * + * @param string $channel * @return void + * @throws \Throwable */ - public function failed(\Throwable $exception): void + protected function deliverVia(string $channel): void { - Log::error("Notification dispatch failed: " . $exception->getMessage()); + if (DeliveryLog::alreadySent((string) $this->notificationId, $channel)) { + return; + } + + if (!$this->notification->shouldSendVia($this->notifiable, $channel)) { + $this->record($channel, 'skipped'); + + return; + } + + if ($this->throttled($channel)) { + $this->record($channel, 'throttled'); + + return; + } + + if (!NotificationEvents::sending($this->notifiable, $this->notification, $channel)) { + $this->record($channel, 'cancelled'); + + return; + } + + try { + app(NotificationManager::class) + ->channel($channel) + ->send($this->notifiable, $this->notification); + } catch (\Throwable $e) { + $this->record($channel, 'failed', $e->getMessage()); + NotificationEvents::failed($this->notifiable, $this->notification, $channel, $e); + + throw $e; + } + + $this->record($channel, 'sent'); + NotificationEvents::sent($this->notifiable, $this->notification, $channel); + } + + /** + * Check if the notification reached its limit on a channel + * + * @param string $channel + * @return bool + */ + protected function throttled(string $channel): bool + { + $rule = $this->notification->throttle(); + + if (!is_array($rule) || empty($rule['max']) || empty($rule['per'])) { + return false; + } + + return DeliveryLog::countSent( + $this->notifiable, + get_class($this->notification), + $channel, + time() - (int) $rule['per'] + ) >= (int) $rule['max']; + } + + /** + * Write what happened on a channel to the delivery log + * + * @param string $channel + * @param string $status + * @param string|null $error + * @return void + */ + protected function record(string $channel, string $status, ?string $error = null): void + { + DeliveryLog::record((string) $this->notificationId, $channel, $this->notifiable, $this->notification, $status, $error); } } diff --git a/src/NotificationEvents.php b/src/NotificationEvents.php new file mode 100644 index 0000000..909fd1c --- /dev/null +++ b/src/NotificationEvents.php @@ -0,0 +1,116 @@ +> + */ + private static array $listeners = []; + + /** + * Register a listener for an event + * + * @param string $event + * @param callable $listener + * @return void + */ + public static function listen(string $event, callable $listener): void + { + self::$listeners[$event][] = $listener; + } + + /** + * Announce a delivery that is about to happen and learn whether it may go on + * + * @param mixed $notifiable + * @param Notification $notification + * @param string $channel + * @return bool + */ + public static function sending(mixed $notifiable, Notification $notification, string $channel): bool + { + foreach (self::run(self::SENDING, [$notifiable, $notification, $channel]) as $result) { + if ($result === false) { + return false; + } + } + + return true; + } + + /** + * Announce a delivery that succeeded + * + * @param mixed $notifiable + * @param Notification $notification + * @param string $channel + * @return void + */ + public static function sent(mixed $notifiable, Notification $notification, string $channel): void + { + self::run(self::SENT, [$notifiable, $notification, $channel]); + } + + /** + * Announce a delivery that failed + * + * @param mixed $notifiable + * @param Notification $notification + * @param string $channel + * @param \Throwable $exception + * @return void + */ + public static function failed(mixed $notifiable, Notification $notification, string $channel, \Throwable $exception): void + { + self::run(self::FAILED, [$notifiable, $notification, $channel, $exception]); + } + + /** + * Remove every listener + * + * @return void + */ + public static function flush(): void + { + self::$listeners = []; + } + + /** + * Call the listeners of an event, isolating a listener that throws + * + * @param string $event + * @param array $arguments + * @return array + */ + private static function run(string $event, array $arguments): array + { + $results = []; + + foreach (self::$listeners[$event] ?? [] as $listener) { + try { + $results[] = $listener(...$arguments); + } catch (\Throwable $e) { + // A broken listener must never stop or fail a delivery. + try { + Log::error("Notification {$event} listener failed: " . $e->getMessage()); + } catch (\Throwable) { + } + } + } + + return $results; + } +} diff --git a/src/NotificationManager.php b/src/NotificationManager.php index 91cbec6..1b7e585 100644 --- a/src/NotificationManager.php +++ b/src/NotificationManager.php @@ -6,10 +6,19 @@ use Doppar\Notifier\Channels\SlackChannel; use Doppar\Notifier\Channels\DiscordChannel; use Doppar\Notifier\Channels\DatabaseChannel; +use Doppar\Notifier\Channels\MailChannel; +use Doppar\Notifier\Channels\WebhookChannel; use Doppar\Notifier\Channels\Contracts\ChannelDriver; class NotificationManager { + /** + * The channels that ship with the package + * + * @var array + */ + public const BUILT_IN = ['database', 'mail', 'slack', 'discord', 'webhook']; + /** * Cached channel driver instances * @@ -72,8 +81,10 @@ protected function resolveChannel(string $channel): ChannelDriver return match($channel) { 'database' => new DatabaseChannel($this->app), + 'mail' => new MailChannel($this->app), 'slack' => new SlackChannel($this->app), 'discord' => new DiscordChannel($this->app), + 'webhook' => new WebhookChannel($this->app), default => throw new \InvalidArgumentException("Notification channel [{$channel}] is not supported."), }; } @@ -110,8 +121,8 @@ public function extend(string $channel, string $driverClass): self */ public function hasChannel(string $channel): bool { - return isset($this->customDrivers[$channel]) || - in_array($channel, ['database', 'slack', 'discord']); + return isset($this->customDrivers[$channel]) || + in_array($channel, self::BUILT_IN, true); } /** @@ -121,10 +132,7 @@ public function hasChannel(string $channel): bool */ public function getChannels(): array { - $builtIn = ['database', 'slack', 'discord']; - $custom = array_keys($this->customDrivers); - - return array_merge($builtIn, $custom); + return array_merge(self::BUILT_IN, array_keys($this->customDrivers)); } /** diff --git a/src/NotifierLauncher.php b/src/NotifierLauncher.php index a2d0956..b6e7413 100644 --- a/src/NotifierLauncher.php +++ b/src/NotifierLauncher.php @@ -6,6 +6,7 @@ use Phaseolies\Launchers\ServiceLauncher; use Doppar\Notifier\NotificationManager; use Doppar\Notifier\Console\Commands\MakeNotificationCommand; +use Doppar\Notifier\Console\Commands\PruneNotificationsCommand; class NotifierLauncher extends ServiceLauncher implements GhostableLauncher { @@ -35,7 +36,8 @@ public function launch(): void ], 'migrations'); $this->commands([ - MakeNotificationCommand::class + MakeNotificationCommand::class, + PruneNotificationsCommand::class, ]); } diff --git a/src/Support/DeliveryLog.php b/src/Support/DeliveryLog.php new file mode 100644 index 0000000..ebf29ed --- /dev/null +++ b/src/Support/DeliveryLog.php @@ -0,0 +1,170 @@ +where('delivery_key', self::key($notificationId, $channel)) + ->first(); + + return $row !== null && $row->status === 'sent'; + }, false); + } + + /** + * Record what happened to a notification on one channel + * + * @param string $notificationId + * @param string $channel + * @param mixed $notifiable + * @param Notification $notification + * @param string $status + * @param string|null $error + * @return void + */ + public static function record( + string $notificationId, + string $channel, + mixed $notifiable, + Notification $notification, + string $status, + ?string $error = null + ): void { + self::guard(function () use ($notificationId, $channel, $notifiable, $notification, $status, $error): void { + $now = date('Y-m-d H:i:s'); + $attempted = in_array($status, ['sent', 'failed'], true); + $row = NotificationDelivery::query() + ->where('delivery_key', self::key($notificationId, $channel)) + ->first(); + + if ($row === null) { + NotificationDelivery::create([ + 'delivery_key' => self::key($notificationId, $channel), + 'notification_id' => $notificationId, + 'notification_type' => get_class($notification), + 'notifiable_type' => Notifiables::type($notifiable), + 'notifiable_id' => (int) Notifiables::key($notifiable), + 'channel' => $channel, + 'status' => $status, + 'attempts' => $attempted ? 1 : 0, + 'error' => $error, + 'sent_at' => $status === 'sent' ? $now : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return; + } + + $row->status = $status; + $row->attempts = (int) $row->attempts + ($attempted ? 1 : 0); + $row->error = $status === 'sent' ? null : $error; + $row->sent_at = $status === 'sent' ? $now : $row->sent_at; + $row->updated_at = $now; + $row->save(); + }, null); + } + + /** + * Count the deliveries a channel made to a notifiable since a time + * + * @param mixed $notifiable + * @param string $notificationClass + * @param string $channel + * @param int $since Unix timestamp + * @return int + */ + public static function countSent(mixed $notifiable, string $notificationClass, string $channel, int $since): int + { + return self::guard(function () use ($notifiable, $notificationClass, $channel, $since): int { + return (int) NotificationDelivery::query() + ->where('notifiable_type', Notifiables::type($notifiable)) + ->where('notifiable_id', Notifiables::key($notifiable)) + ->where('notification_type', $notificationClass) + ->where('channel', $channel) + ->where('status', 'sent') + ->where('sent_at', '>=', date('Y-m-d H:i:s', $since)) + ->count(); + }, 0); + } + + /** + * Forget that the log was found unusable + * + * @return void + */ + public static function reset(): void + { + self::$broken = false; + } + + /** + * Get the unique key of a notification on a channel + * + * @param string $notificationId + * @param string $channel + * @return string + */ + private static function key(string $notificationId, string $channel): string + { + return substr($notificationId . '|' . $channel, 0, 100); + } + + /** + * Run a log operation and fall back when the log cannot be used + * + * @param \Closure $operation + * @param mixed $fallback + * @return mixed + */ + private static function guard(\Closure $operation, mixed $fallback): mixed + { + if (self::$broken) { + return $fallback; + } + + try { + return $operation(); + } catch (\Throwable $e) { + self::$broken = true; + + try { + Log::warning('Notification delivery log is unavailable, run the notifier migrations: ' . $e->getMessage()); + } catch (\Throwable) { + // The log is best effort, so even a failing logger must not stop a delivery. + } + + return $fallback; + } + } +} diff --git a/src/Support/Id.php b/src/Support/Id.php new file mode 100644 index 0000000..b16b814 --- /dev/null +++ b/src/Support/Id.php @@ -0,0 +1,20 @@ +getKey() + : ($notifiable->id ?? null); + + return is_int($key) || (is_string($key) && $key !== '') ? $key : null; + } + + /** + * Read an attribute of a notifiable entity without failing when it has none + * + * Models keep their attributes behind magic accessors that isset() cannot see, + * so they are asked for their attributes instead. + * + * @param mixed $notifiable + * @param string $name + * @return mixed + */ + public static function attribute(mixed $notifiable, string $name): mixed + { + if (!is_object($notifiable)) { + return null; + } + + if (method_exists($notifiable, 'getAttributes')) { + return array_key_exists($name, $notifiable->getAttributes()) ? $notifiable->{$name} : null; + } + + return property_exists($notifiable, $name) ? $notifiable->{$name} : null; + } + + /** + * Get the key of a notifiable entity or fail when it has none + * + * @param mixed $notifiable + * @return int|string + * @throws \RuntimeException + */ + public static function requireKey(mixed $notifiable): int|string + { + $key = self::key($notifiable); + + if ($key === null) { + throw new \RuntimeException( + 'The notifiable has no key. Save it before sending it a notification.' + ); + } + + return $key; + } + + /** + * Get the type stored for a notifiable entity + * + * @param mixed $notifiable + * @return string + */ + public static function type(mixed $notifiable): string + { + return is_object($notifiable) ? get_class($notifiable) : gettype($notifiable); + } +} diff --git a/src/Supports/Facades/Notification.php b/src/Supports/Facades/Notification.php index 7632604..4fabfa0 100644 --- a/src/Supports/Facades/Notification.php +++ b/src/Supports/Facades/Notification.php @@ -7,6 +7,8 @@ use Doppar\Notifier\Concerns\QueryNotificationBuilder; use Doppar\Notifier\Concerns\NotificationBuilder; use Doppar\Notifier\Concerns\BulkNotificationBuilder; +use Doppar\Notifier\NotificationEvents; +use Doppar\Notifier\Testing\NotificationFake; class Notification { @@ -53,4 +55,70 @@ public static function schedule(ContractsNotification $notification): ScheduledN { return new ScheduledNotificationBuilder($notification); } + + /** + * Capture notifications instead of sending them + * + * @return NotificationFake + */ + public static function fake(): NotificationFake + { + return NotificationFake::activate(); + } + + /** + * Send notifications for real again after fake() + * + * @return void + */ + public static function unfake(): void + { + NotificationFake::deactivate(); + } + + /** + * Listen for a delivery that is about to be made + * + * The listener receives the notifiable, the notification and the channel. + * Returning false cancels that delivery. + * + * @param callable $listener + * @return void + */ + public static function sending(callable $listener): void + { + NotificationEvents::listen(NotificationEvents::SENDING, $listener); + } + + /** + * Listen for a delivery that succeeded + * + * @param callable $listener + * @return void + */ + public static function sent(callable $listener): void + { + NotificationEvents::listen(NotificationEvents::SENT, $listener); + } + + /** + * Listen for a delivery that failed + * + * @param callable $listener + * @return void + */ + public static function failed(callable $listener): void + { + NotificationEvents::listen(NotificationEvents::FAILED, $listener); + } + + /** + * Remove every delivery listener + * + * @return void + */ + public static function flushListeners(): void + { + NotificationEvents::flush(); + } } diff --git a/src/Testing/NotificationFake.php b/src/Testing/NotificationFake.php new file mode 100644 index 0000000..0d81c1a --- /dev/null +++ b/src/Testing/NotificationFake.php @@ -0,0 +1,254 @@ +, delay: int, immediate: bool}> + */ + private array $sent = []; + + /** + * Start capturing notifications instead of sending them + * + * @return self + */ + public static function activate(): self + { + return self::$active = new self(); + } + + /** + * Go back to sending notifications for real + * + * @return void + */ + public static function deactivate(): void + { + self::$active = null; + } + + /** + * Get the fake in use + * + * @return self|null + */ + public static function current(): ?self + { + return self::$active; + } + + /** + * Record a notification that would have been sent + * + * @param mixed $notifiable + * @param Notification $notification + * @param array $channels + * @param int $delay + * @param bool $immediate + * @return string + */ + public function record(mixed $notifiable, Notification $notification, array $channels, int $delay, bool $immediate): string + { + $id = Id::uuid(); + + $this->sent[] = [ + 'id' => $id, + 'notifiable' => $notifiable, + 'notification' => $notification, + 'channels' => array_values($channels), + 'delay' => $delay, + 'immediate' => $immediate, + ]; + + return $id; + } + + /** + * Get what was sent, optionally only to one notifiable and of one kind + * + * @param mixed $notifiable + * @param string|\Closure|null $notification + * @return array, delay: int, immediate: bool}> + */ + public function sent(mixed $notifiable = null, string|\Closure|null $notification = null): array + { + return array_values(array_filter($this->sent, function (array $entry) use ($notifiable, $notification): bool { + if ($notifiable !== null && !$this->sameNotifiable($entry['notifiable'], $notifiable)) { + return false; + } + + return $notification === null || $this->matches($entry, $notification); + })); + } + + /** + * Assert that a notification was sent to a notifiable + * + * @param mixed $notifiable + * @param string|\Closure $notification + * @param int|null $times + * @return void + */ + public function assertSentTo(mixed $notifiable, string|\Closure $notification, ?int $times = null): void + { + $count = count($this->sent($notifiable, $notification)); + $name = $this->describe($notification); + + if ($count === 0 || ($times !== null && $count !== $times)) { + $this->fail($times === null + ? "The expected [{$name}] notification was not sent to the notifiable." + : "Expected [{$name}] to be sent {$times} time(s) to the notifiable, but it was sent {$count} time(s)."); + } + } + + /** + * Assert that a notification was not sent to a notifiable + * + * @param mixed $notifiable + * @param string|\Closure $notification + * @return void + */ + public function assertNotSentTo(mixed $notifiable, string|\Closure $notification): void + { + if ($this->sent($notifiable, $notification) !== []) { + $this->fail('The unexpected [' . $this->describe($notification) . '] notification was sent to the notifiable.'); + } + } + + /** + * Assert that a notification was sent a number of times, to anybody + * + * @param string|\Closure $notification + * @param int $times + * @return void + */ + public function assertSentTimes(string|\Closure $notification, int $times): void + { + $count = count($this->sent(null, $notification)); + + if ($count !== $times) { + $this->fail("Expected [" . $this->describe($notification) . "] to be sent {$times} time(s), but it was sent {$count} time(s)."); + } + } + + /** + * Assert that a notification went out through a channel + * + * @param mixed $notifiable + * @param string $notification + * @param string $channel + * @return void + */ + public function assertSentVia(mixed $notifiable, string $notification, string $channel): void + { + foreach ($this->sent($notifiable, $notification) as $entry) { + if (in_array($channel, $entry['channels'], true)) { + return; + } + } + + $this->fail("The [{$notification}] notification was not sent through the [{$channel}] channel."); + } + + /** + * Assert that nothing was sent + * + * @return void + */ + public function assertNothingSent(): void + { + if ($this->sent !== []) { + $this->fail('Expected no notifications, but ' . count($this->sent) . ' were sent.'); + } + } + + /** + * Assert the total number of notifications sent + * + * @param int $count + * @return void + */ + public function assertCount(int $count): void + { + if (count($this->sent) !== $count) { + $this->fail("Expected {$count} notification(s) to be sent, but " . count($this->sent) . ' were sent.'); + } + } + + /** + * Check if two values are the same notifiable entity + * + * @param mixed $a + * @param mixed $b + * @return bool + */ + private function sameNotifiable(mixed $a, mixed $b): bool + { + if ($a === $b) { + return true; + } + + $keyA = Notifiables::key($a); + + return $keyA !== null + && Notifiables::type($a) === Notifiables::type($b) + && (string) $keyA === (string) Notifiables::key($b); + } + + /** + * Check if a recorded notification matches a class name or a callback + * + * @param array{id: string, notifiable: mixed, notification: Notification, channels: array, delay: int, immediate: bool} $entry + * @param string|\Closure $notification + * @return bool + */ + private function matches(array $entry, string|\Closure $notification): bool + { + if ($notification instanceof \Closure) { + return (bool) $notification($entry['notification'], $entry['channels'], $entry['notifiable']); + } + + return $entry['notification'] instanceof $notification; + } + + /** + * Get a readable name for what an assertion looked for + * + * @param string|\Closure $notification + * @return string + */ + private function describe(string|\Closure $notification): string + { + return $notification instanceof \Closure ? 'matching' : $notification; + } + + /** + * Fail an assertion + * + * @param string $message + * @return never + */ + private function fail(string $message): never + { + if (class_exists(\PHPUnit\Framework\AssertionFailedError::class)) { + throw new \PHPUnit\Framework\AssertionFailedError($message); + } + + throw new \RuntimeException($message); + } +} diff --git a/src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php b/src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php new file mode 100644 index 0000000..be2f940 --- /dev/null +++ b/src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('delivery_key', 100)->unique(); + $table->string('notification_id', 36)->index(); + $table->string('notification_type')->index(); + $table->string('notifiable_type'); + $table->unsignedBigInteger('notifiable_id'); + $table->string('channel', 64); + $table->string('status', 16)->index(); + $table->unsignedSmallInteger('attempts')->default(0); + $table->text('error')->nullable(); + $table->timestamp('sent_at')->index()->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('notification_deliveries'); + } +}; diff --git a/tests/Mock/MockContainer.php b/tests/Mock/MockContainer.php index a16f93b..b4d73cb 100644 --- a/tests/Mock/MockContainer.php +++ b/tests/Mock/MockContainer.php @@ -17,6 +17,17 @@ public function storagePath(string $path = ''): string return $path ? $base . DIRECTORY_SEPARATOR . $path : $base; } + public function basePath(string $path = ''): string + { + $base = sys_get_temp_dir() . '/phaseolies_base'; + + if (!is_dir($base)) { + mkdir($base, 0777, true); + } + + return $path ? $base . DIRECTORY_SEPARATOR . $path : $base; + } + public function runningInConsole(): bool { return true; diff --git a/tests/Mock/Models/TestUser.php b/tests/Mock/Models/TestUser.php new file mode 100644 index 0000000..f281e85 --- /dev/null +++ b/tests/Mock/Models/TestUser.php @@ -0,0 +1,40 @@ +muted)), true); + } +} diff --git a/tests/Mock/Notifications/ConfigurableNotification.php b/tests/Mock/Notifications/ConfigurableNotification.php new file mode 100644 index 0000000..7002302 --- /dev/null +++ b/tests/Mock/Notifications/ConfigurableNotification.php @@ -0,0 +1,86 @@ + $channels + * @param array $options + */ + public function __construct(public array $channels = ['database'], public array $options = []) + { + } + + public function channels($notifiable): array + { + return $this->channels; + } + + public function content($notifiable): array + { + return ['subject' => 'Hello', 'message' => 'World', 'text' => 'Hello Slack', 'content' => 'Hello Discord']; + } + + public function toMail($notifiable): array + { + return [ + 'subject' => 'Welcome', + 'greeting' => 'Hi ' . ($notifiable->name ?? 'there'), + 'lines' => ['Your account is ready.'], + 'action' => ['text' => 'Open', 'url' => 'https://app.example.com/start'], + ]; + } + + public function toWebhook($notifiable): array + { + return ['event' => 'welcome', 'user' => $notifiable->id]; + } + + public function shouldSend($notifiable): bool + { + return $this->options['shouldSend'] ?? true; + } + + public function shouldSendVia($notifiable, string $channel): bool + { + return !in_array($channel, $this->options['skipChannels'] ?? [], true); + } + + public function deliveryDelay(): int + { + return $this->options['delay'] ?? 0; + } + + public function priority(): int + { + return $this->options['priority'] ?? 0; + } + + public function uniqueId($notifiable): ?string + { + return $this->options['uniqueId'] ?? null; + } + + public function backoff(): int|array|null + { + return $this->options['backoff'] ?? null; + } + + public function onConnection(): ?string + { + return $this->options['connection'] ?? null; + } + + public function throttle(): ?array + { + return $this->options['throttle'] ?? null; + } + + public function metadata(): array + { + return ['source' => 'test']; + } +} diff --git a/tests/Mock/SpyPruneCommand.php b/tests/Mock/SpyPruneCommand.php new file mode 100644 index 0000000..2302606 --- /dev/null +++ b/tests/Mock/SpyPruneCommand.php @@ -0,0 +1,51 @@ + + */ + public array $givenOptions = []; + + /** + * @var array + */ + public array $lines = []; + + /** + * @var array + */ + public array $errors = []; + + /** + * Give the command its options + * + * @param array $options + * @return static + */ + public function withOptions(array $options): static + { + $this->givenOptions = $options; + + return $this; + } + + protected function option($key = null) + { + return $key === null ? $this->givenOptions : ($this->givenOptions[$key] ?? null); + } + + protected function info($string): void + { + $this->lines[] = $string; + } + + protected function error($string): void + { + $this->errors[] = $string; + } +} diff --git a/tests/Support/NotifierTestCase.php b/tests/Support/NotifierTestCase.php new file mode 100644 index 0000000..7532faf --- /dev/null +++ b/tests/Support/NotifierTestCase.php @@ -0,0 +1,250 @@ +}> + */ + protected array $http = []; + + /** + * The answer the faked HTTP transport gives + * + * @var array{status: int, body: string, error: string} + */ + protected array $httpAnswer = ['status' => 200, 'body' => 'ok', 'error' => '']; + + /** + * The mails the faked mail sender received: address, name, the Mailable and the content + * + * @var array}> + */ + protected array $mails = []; + + protected function setUp(): void + { + $container = new MockContainer(); + Container::setInstance($container); + $container->bind('request', fn() => new Request()); + $container->bind('url', fn() => UrlGenerator::class); + $container->bind('db', fn() => new Database('default')); + $container->singleton(NotificationManager::class, fn() => new NotificationManager()); + $container->singleton('log', LoggerService::class); + + config(['database.default' => 'default']); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->createTables(); + + $reflection = new \ReflectionClass(Database::class); + $reflection->getProperty('connections')->setValue(null, ['default' => $this->pdo, 'sqlite' => $this->pdo]); + $reflection->getProperty('transactions')->setValue(null, []); + + if (static::queueAvailable()) { + $this->queue = new QueueManager([ + 'default' => 'database', + 'connections' => [ + 'database' => ['driver' => 'database', 'connection' => 'default'], + 'memory' => ['driver' => 'memory'], + ], + ]); + $container->singleton('queue.worker', fn() => $this->queue); + $container->alias('queue.worker', QueueManager::class); + } + + $this->http = []; + $this->httpAnswer = ['status' => 200, 'body' => 'ok', 'error' => '']; + Http::fake(function (string $url, string $body, array $headers): array { + $this->http[] = ['url' => $url, 'body' => $body, 'headers' => $headers]; + + return $this->httpAnswer; + }); + + $this->mails = []; + MailChannel::using(function (string $address, ?string $name, object $mailable, array $content): void { + $this->mails[] = ['address' => $address, 'name' => $name, 'mailable' => $mailable, 'content' => $content]; + }); + + NotificationEvents::flush(); + NotificationFake::deactivate(); + DeliveryLog::reset(); + } + + protected function tearDown(): void + { + NotificationEvents::flush(); + NotificationFake::deactivate(); + Http::fake(null); + MailChannel::using(null); + DeliveryLog::reset(); + + $reflection = new \ReflectionClass(Database::class); + $reflection->getProperty('connections')->setValue(null, []); + $reflection->getProperty('transactions')->setValue(null, []); + } + + /** + * Check if the installed queue package has the storage drivers the queue tests need + * + * @return bool + */ + protected static function queueAvailable(): bool + { + return interface_exists(\Doppar\Queue\Contracts\QueueDriver::class); + } + + /** + * Skip the test when the installed queue package is too old for it + * + * @return void + */ + protected function requireQueue(): void + { + if (!static::queueAvailable()) { + $this->markTestSkipped('Needs a doppar/queue release with the database, redis and memory drivers.'); + } + } + + /** + * Skip the test when the queue package does not have the priority, unique and backoff features + * + * @return void + */ + protected function requireQueueFeatures(): void + { + $this->requireQueue(); + + if (!method_exists(\Doppar\Queue\Job::class, 'uniqueId')) { + $this->markTestSkipped('Needs a doppar/queue release with priority, unique jobs and backoff.'); + } + } + + /** + * Create the tables the notifier and the queue use + * + * @return void + */ + protected function createTables(): void + { + $this->pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, slack_webhook_url TEXT NULL, discord_webhook_url TEXT NULL, webhook_url TEXT NULL, muted TEXT NULL)'); + $this->pdo->exec('CREATE TABLE notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, notifiable_type TEXT NOT NULL, notifiable_id INTEGER NOT NULL, type TEXT NOT NULL, data TEXT NOT NULL, metadata TEXT, read_at TEXT, created_at TEXT, updated_at TEXT)'); + $this->createDeliveriesTable(); + $this->pdo->exec('CREATE TABLE queue_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, queue TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER DEFAULT 0, priority INTEGER NOT NULL DEFAULT 0, reserved_at INTEGER, lease_expires_at INTEGER, available_at INTEGER NOT NULL, unique_key TEXT, created_at INTEGER NOT NULL)'); + $this->pdo->exec('CREATE UNIQUE INDEX idx_queue_unique_key ON queue_jobs(unique_key)'); + $this->pdo->exec('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at INTEGER NOT NULL)'); + + foreach ([[1, 'Ann', 'ann@example.com'], [2, 'Bob', 'bob@example.com'], [3, 'Cy', 'cy@example.com']] as [$id, $name, $email]) { + $this->pdo->exec("INSERT INTO users (id, name, email, slack_webhook_url, discord_webhook_url, webhook_url) VALUES ($id, '$name', '$email', 'https://hooks.slack.test/$id', 'https://discord.test/hook/$id', 'https://hooks.example.test/$id')"); + } + } + + /** + * Create the delivery log table, as the package migration defines it + * + * @return void + */ + protected function createDeliveriesTable(): void + { + $this->pdo->exec('CREATE TABLE notification_deliveries (id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_key TEXT NOT NULL UNIQUE, notification_id TEXT NOT NULL, notification_type TEXT NOT NULL, notifiable_type TEXT NOT NULL, notifiable_id INTEGER NOT NULL, channel TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, error TEXT NULL, sent_at TEXT NULL, created_at TEXT NULL, updated_at TEXT NULL)'); + } + + /** + * Get a user + * + * @param int $id + * @return TestUser + */ + protected function user(int $id = 1): TestUser + { + $user = TestUser::query()->where('id', $id)->first(); + $this->assertNotNull($user, "User $id exists"); + + return $user; + } + + /** + * Run a query and return the rows + * + * @param string $sql + * @return array> + */ + protected function rows(string $sql): array + { + return $this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Run a query and return its first value + * + * @param string $sql + * @return mixed + */ + protected function value(string $sql): mixed + { + return $this->pdo->query($sql)->fetchColumn(); + } + + /** + * Create a worker for the notifications queue + * + * @return QueueWorker + */ + protected function worker(): QueueWorker + { + return new QueueWorker($this->queue); + } + + /** + * Make every queued job available now + * + * @return void + */ + protected function releaseQueuedJobs(): void + { + $this->pdo->exec('UPDATE queue_jobs SET available_at = ' . (time() - 1) . ', reserved_at = NULL, lease_expires_at = NULL'); + } + + /** + * Run the queued notification jobs until none is ready + * + * @return int + */ + protected function drain(): int + { + $worker = $this->worker(); + $ran = 0; + + while ($worker->runNextJob('notifications')) { + $ran++; + } + + return $ran; + } +} diff --git a/tests/Unit/BuildersTest.php b/tests/Unit/BuildersTest.php new file mode 100644 index 0000000..351c944 --- /dev/null +++ b/tests/Unit/BuildersTest.php @@ -0,0 +1,124 @@ +requireQueue(); + } + + public function testToSendsToOneEntityWithTheChosenChannels(): void + { + $id = Notification::to($this->user(2))->via(['webhook'])->send(new ConfigurableNotification(['database'])); + $this->drain(); + + $this->assertIsString($id); + $this->assertSame('https://hooks.example.test/2', $this->http[0]['url']); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testToAfterDelays(): void + { + Notification::to($this->user(1))->after(120)->send(new ConfigurableNotification(['database'])); + + $this->assertEqualsWithDelta(time() + 120, (int) $this->value('SELECT available_at FROM queue_jobs'), 5); + } + + public function testSendNowDeliversWithoutTheQueue(): void + { + Notification::to($this->user(1))->sendNow(new ConfigurableNotification(['database'])); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM queue_jobs')); + } + + public function testToManyReturnsOneIdPerRecipient(): void + { + $ids = Notification::toMany([$this->user(1), $this->user(2), $this->user(3)]) + ->batchSize(2) + ->send(new ConfigurableNotification(['database'])); + $this->drain(); + + $this->assertCount(3, $ids); + $this->assertCount(3, array_unique($ids)); + $this->assertSame([1, 2, 3], array_map('intval', array_column($this->rows('SELECT notifiable_id FROM notifications ORDER BY notifiable_id'), 'notifiable_id'))); + } + + public function testToManyAcceptsAGenerator(): void + { + $generator = (function () { + yield $this->user(1); + yield $this->user(3); + })(); + + $ids = Notification::toMany($generator)->send(new ConfigurableNotification(['database'])); + + $this->assertCount(2, $ids); + } + + public function testToAllSendsToEveryMatchingRecord(): void + { + $count = Notification::toAll(TestUser::class)->chunkSize(2)->send(new ConfigurableNotification(['database'])); + $this->drain(); + + $this->assertSame(3, $count); + $this->assertSame(3, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testToAllWithAFilter(): void + { + $count = Notification::toAll(TestUser::class)->where('id', 2)->send(new ConfigurableNotification(['database'])); + $this->drain(); + + $this->assertSame(1, $count); + $this->assertSame(2, (int) $this->value('SELECT notifiable_id FROM notifications')); + } + + public function testScheduleAfterAndAt(): void + { + Notification::schedule(new ConfigurableNotification(['database']))->to($this->user(1))->after(3600); + Notification::schedule(new ConfigurableNotification(['database']))->to($this->user(2))->at(time() + 7200); + + $times = array_map('intval', array_column($this->rows('SELECT available_at FROM queue_jobs ORDER BY id'), 'available_at')); + $this->assertEqualsWithDelta(time() + 3600, $times[0], 5); + $this->assertEqualsWithDelta(time() + 7200, $times[1], 5); + $this->assertSame(0, $this->drain(), 'neither is due'); + } + + public function testScheduleWithoutARecipientFails(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No recipient'); + + Notification::schedule(new ConfigurableNotification(['database']))->after(10); + } + + public function testTheNotifyHelper(): void + { + $id = notify($this->user(1))->send(new ConfigurableNotification(['database'])); + + $this->assertIsString($id); + $this->assertSame(1, Queue::size('notifications')); + $this->assertInstanceOf(Notification::class, notify()); + } + + public function testTheSameNotificationObjectCanGoToManyPeopleWithSeparateDeliveries(): void + { + $notification = new ConfigurableNotification(['database']); + + Notification::toMany([$this->user(1), $this->user(2)])->send($notification); + $this->drain(); + + $this->assertSame(2, (int) $this->value('SELECT count(DISTINCT notification_id) FROM notification_deliveries'), 'one notification id per recipient'); + $this->assertSame(2, (int) $this->value("SELECT count(*) FROM notification_deliveries WHERE status = 'sent'")); + } +} diff --git a/tests/Unit/ChannelsTest.php b/tests/Unit/ChannelsTest.php new file mode 100644 index 0000000..8beaa40 --- /dev/null +++ b/tests/Unit/ChannelsTest.php @@ -0,0 +1,164 @@ +send($this->user(2), new ConfigurableNotification(['database'])); + + $row = $this->rows('SELECT * FROM notifications')[0]; + $this->assertSame(TestUser::class, $row['notifiable_type']); + $this->assertSame(2, (int) $row['notifiable_id']); + $this->assertSame(ConfigurableNotification::class, $row['type']); + $this->assertSame('Hello', json_decode($row['data'], true)['subject']); + $this->assertSame(['source' => 'test'], json_decode($row['metadata'], true)); + $this->assertNull($row['read_at']); + $this->assertNotEmpty($row['created_at']); + } + + public function testTheDatabaseChannelRefusesAnEntityWithoutAKey(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('no key'); + + (new DatabaseChannel())->send(new TestUser(), new ConfigurableNotification(['database'])); + } + + public function testSlackPostsTheContentToTheWebhook(): void + { + (new SlackChannel())->send($this->user(1), new ConfigurableNotification(['slack'])); + + $this->assertCount(1, $this->http); + $this->assertSame('https://hooks.slack.test/1', $this->http[0]['url']); + $this->assertContains('Content-Type: application/json', $this->http[0]['headers']); + $payload = json_decode($this->http[0]['body'], true); + $this->assertSame('Hello Slack', $payload['text']); + $this->assertSame('Doppar Bot', $payload['username']); + $this->assertSame(':bell:', $payload['icon_emoji']); + } + + public function testSlackFailureThrowsSoTheQueueRetries(): void + { + $this->httpAnswer = ['status' => 404, 'body' => 'no_service', 'error' => '']; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('HTTP 404'); + + (new SlackChannel())->send($this->user(1), new ConfigurableNotification(['slack'])); + } + + public function testSlackWithoutAWebhookThrows(): void + { + $this->pdo->exec('UPDATE users SET slack_webhook_url = NULL WHERE id = 1'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Slack webhook URL is missing'); + + (new SlackChannel())->send($this->user(1), new ConfigurableNotification(['slack'])); + $this->assertSame([], $this->http, 'nothing was sent'); + } + + public function testSlackAnswerThatIsNotOkIsAFailureEvenWith200(): void + { + $this->httpAnswer = ['status' => 200, 'body' => 'invalid_payload', 'error' => '']; + + $this->expectException(\RuntimeException::class); + + (new SlackChannel())->send($this->user(1), new ConfigurableNotification(['slack'])); + } + + public function testDiscordPostsAndFailsOnAnErrorStatus(): void + { + $this->httpAnswer = ['status' => 204, 'body' => '', 'error' => '']; + (new DiscordChannel())->send($this->user(1), new ConfigurableNotification(['discord'])); + + $this->assertSame('https://discord.test/hook/1', $this->http[0]['url']); + $this->assertSame('Hello Discord', json_decode($this->http[0]['body'], true)['content']); + + $this->httpAnswer = ['status' => 429, 'body' => '', 'error' => '']; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('429'); + (new DiscordChannel())->send($this->user(1), new ConfigurableNotification(['discord'])); + } + + public function testTheWebhookChannelPostsTheWebhookContent(): void + { + $this->httpAnswer = ['status' => 202, 'body' => '', 'error' => '']; + + (new WebhookChannel())->send($this->user(3), new ConfigurableNotification(['webhook'])); + + $this->assertSame('https://hooks.example.test/3', $this->http[0]['url']); + $this->assertSame(['event' => 'welcome', 'user' => 3], json_decode($this->http[0]['body'], true)); + $this->assertSame(['Content-Type: application/json'], $this->http[0]['headers'], 'no secret, no signature'); + } + + public function testTheWebhookBodyIsSignedWhenThereIsASecret(): void + { + $notification = new class (['webhook']) extends ConfigurableNotification { + public function toWebhook($notifiable): array + { + return ['payload' => ['id' => 7], 'secret' => 's3cret', 'headers' => ['X-Trace' => 'abc', "Evil\r\nInjected" => 'x', 'Content-Type' => 'text/plain']]; + } + }; + + (new WebhookChannel())->send($this->user(1), $notification); + + $call = $this->http[0]; + $this->assertSame('{"id":7}', $call['body'], 'only the payload key is sent'); + $this->assertContains('X-Notification-Signature: sha256=' . hash_hmac('sha256', '{"id":7}', 's3cret'), $call['headers']); + $this->assertContains('X-Trace: abc', $call['headers']); + $this->assertSame(['Content-Type: application/json'], array_values(array_filter($call['headers'], fn ($h) => stripos($h, 'content-type') === 0)), 'the content type cannot be overridden'); + $this->assertSame([], array_values(array_filter($call['headers'], fn ($h) => str_contains($h, 'Injected'))), 'a header with a line break is dropped'); + } + + public function testTheWebhookSecretCanComeFromTheConfig(): void + { + config(['notification.webhook.secret' => 'from-config']); + + (new WebhookChannel())->send($this->user(1), new ConfigurableNotification(['webhook'])); + + $this->assertContains('X-Notification-Signature: sha256=' . hash_hmac('sha256', $this->http[0]['body'], 'from-config'), $this->http[0]['headers']); + config(['notification.webhook.secret' => null]); + } + + public function testTheWebhookNeedsAnHttpAddressAndASuccessfulAnswer(): void + { + foreach ([null, 'ftp://example.test/x', 'javascript:alert(1)', 'not a url'] as $url) { + $this->pdo->prepare('UPDATE users SET webhook_url = ? WHERE id = 1')->execute([$url]); + try { + (new WebhookChannel())->send($this->user(1), new ConfigurableNotification(['webhook'])); + $this->fail('A webhook to ' . var_export($url, true) . ' must be refused.'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Webhook URL', $e->getMessage()); + } + } + $this->assertSame([], $this->http); + + $this->pdo->exec("UPDATE users SET webhook_url = 'https://hooks.example.test/1' WHERE id = 1"); + $this->httpAnswer = ['status' => 500, 'body' => 'boom', 'error' => '']; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('HTTP 500'); + (new WebhookChannel())->send($this->user(1), new ConfigurableNotification(['webhook'])); + } + + public function testEachChannelGetsItsOwnContent(): void + { + $notification = new ConfigurableNotification(); + + $this->assertSame('Hello', $notification->contentFor('database', null)['subject'], 'no toDatabase: the shared content'); + $this->assertSame('Welcome', $notification->contentFor('mail', $this->user(1))['subject'], 'toMail'); + $this->assertSame(['event' => 'welcome', 'user' => 1], $notification->contentFor('webhook', $this->user(1))); + $this->assertSame('Hello Slack', $notification->contentFor('slack', null)['text'], 'no toSlack: the shared content'); + $this->assertSame('Hello', $notification->contentFor('some-new_channel', null)['subject']); + } +} diff --git a/tests/Unit/ContractTest.php b/tests/Unit/ContractTest.php new file mode 100644 index 0000000..05b749b --- /dev/null +++ b/tests/Unit/ContractTest.php @@ -0,0 +1,124 @@ +assertTrue($n->shouldSend(null)); + $this->assertTrue($n->shouldSendVia(null, 'mail')); + $this->assertSame(0, $n->deliveryDelay()); + $this->assertSame(0, $n->priority()); + $this->assertNull($n->uniqueId(null)); + $this->assertNull($n->backoff()); + $this->assertNull($n->onConnection()); + $this->assertNull($n->throttle()); + $this->assertSame([], (new class extends Notification { + public function channels($notifiable): array + { + return []; + } + + public function content($notifiable): array + { + return []; + } + })->metadata()); + } + + public function testContentForFallsBackToContentAndRoutesByName(): void + { + $n = new TestEmailNotification('Subject', 'Message'); + + $this->assertSame(['subject' => 'Subject', 'message' => 'Message'], $n->contentFor('slack', null)); + $this->assertSame($n->content(null), $n->contentFor('', null), 'an empty channel name never calls a method called "to"'); + } + + public function testAToMethodThatReturnsNothingUsefulGivesAnEmptyContent(): void + { + $n = new class extends Notification { + public function channels($notifiable): array + { + return ['mail']; + } + + public function content($notifiable): array + { + return ['shared' => true]; + } + + public function toMail($notifiable) + { + return null; + } + }; + + $this->assertSame([], $n->contentFor('mail', null)); + } + + public function testUuidsAreVersion4AndDifferent(): void + { + $seen = []; + for ($i = 0; $i < 200; $i++) { + $id = Id::uuid(); + $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $id); + $seen[$id] = true; + } + + $this->assertCount(200, $seen); + } + + public function testNotifiableKeysAndTypes(): void + { + $this->assertEquals(1, Notifiables::key($this->user(1)), 'a database key may come back as a string'); + $this->assertNull(Notifiables::key(new TestUser()), 'not saved: no key'); + $this->assertNull(Notifiables::key('a string')); + $this->assertNull(Notifiables::key(null)); + $this->assertSame(7, Notifiables::key((object) ['id' => 7])); + $this->assertSame('abc', Notifiables::key((object) ['id' => 'abc'])); + $this->assertNull(Notifiables::key((object) ['id' => ''])); + $this->assertSame(TestUser::class, Notifiables::type($this->user(1))); + + $this->expectException(\RuntimeException::class); + Notifiables::requireKey(new TestUser()); + } + + public function testAttributesAreReadWithoutIssetOnModelsAndWithoutWarningsOnObjects(): void + { + $this->assertSame('Ann', Notifiables::attribute($this->user(1), 'name')); + $this->assertNull(Notifiables::attribute($this->user(1), 'nonexistent')); + $this->assertSame('x', Notifiables::attribute((object) ['email' => 'x'], 'email')); + $this->assertNull(Notifiables::attribute((object) [], 'email')); + $this->assertNull(Notifiables::attribute('nope', 'email')); + } + + public function testTheManagerKnowsTheNewChannels(): void + { + $manager = new NotificationManager(); + + foreach (['database', 'mail', 'slack', 'discord', 'webhook'] as $channel) { + $this->assertTrue($manager->hasChannel($channel), $channel); + $this->assertContains($channel, $manager->getChannels()); + $this->assertInstanceOf(\Doppar\Notifier\Channels\Contracts\ChannelDriver::class, $manager->channel($channel)); + } + $this->assertFalse($manager->hasChannel('sms')); + } + + public function testAChannelCanBeCreatedWithoutTheApplication(): void + { + $this->assertInstanceOf(\Doppar\Notifier\Channels\WebhookChannel::class, new \Doppar\Notifier\Channels\WebhookChannel()); + $this->assertInstanceOf(\Doppar\Notifier\Channels\MailChannel::class, new \Doppar\Notifier\Channels\MailChannel(null)); + } +} diff --git a/tests/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php new file mode 100644 index 0000000..d677750 --- /dev/null +++ b/tests/Unit/DispatcherTest.php @@ -0,0 +1,230 @@ +requireQueue(); + } + + private function retry(int $times): void + { + for ($i = 0; $i < $times; $i++) { + $this->releaseQueuedJobs(); + $this->drain(); + } + } + + public function testAFailingChannelDoesNotRepeatTheOthers(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack']))->send(); + $this->retry(3); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications'), 'one database row, not one per retry'); + $this->assertCount(3, $this->http, 'slack was tried 3 times'); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM failed_jobs'), 'only the slack job failed'); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM queue_jobs')); + } + + public function testTheLogShowsWhatHappenedOnEachChannel(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack']))->send(); + $this->retry(3); + + $rows = array_column($this->rows('SELECT channel, status, attempts, error FROM notification_deliveries'), null, 'channel'); + $this->assertSame(['sent', 1, null], [$rows['database']['status'], (int) $rows['database']['attempts'], $rows['database']['error']]); + $this->assertSame('failed', $rows['slack']['status']); + $this->assertSame(3, (int) $rows['slack']['attempts']); + $this->assertStringContainsString('HTTP 500', $rows['slack']['error']); + } + + public function testARetryThatSucceedsClearsTheError(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + $this->user(1)->notify(new ConfigurableNotification(['slack']))->send(); + $this->retry(1); + $this->assertSame('failed', $this->value('SELECT status FROM notification_deliveries')); + + $this->httpAnswer = ['status' => 200, 'body' => 'ok', 'error' => '']; + $this->retry(1); + + $row = $this->rows('SELECT status, attempts, error, sent_at FROM notification_deliveries')[0]; + $this->assertSame('sent', $row['status']); + $this->assertSame(2, (int) $row['attempts']); + $this->assertNull($row['error']); + $this->assertNotNull($row['sent_at']); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM failed_jobs')); + } + + public function testAJobWithSeveralChannelsTriesAllOfThemAndThrowsAtTheEnd(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + $job = new NotificationDispatcher($this->user(1), new ConfigurableNotification(), ['slack', 'database', 'mail']); + + try { + $job->handle(); + $this->fail('The slack failure must be thrown.'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Slack', $e->getMessage()); + } + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications'), 'database ran after the failed slack'); + $this->assertCount(1, $this->mails); + } + + public function testAnOldQueuedJobWithSeveralChannelsDoesNotDuplicateOnRetry(): void + { + // Jobs queued by an earlier version carry all the channels in one job. + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + $id = 'aaaaaaaa-0000-4000-8000-000000000001'; + + foreach ([1, 2, 3] as $attempt) { + $job = new NotificationDispatcher($this->user(1), new ConfigurableNotification(), ['database', 'slack'], $id); + try { + $job->handle(); + } catch (\RuntimeException) { + } + } + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertCount(3, $this->http); + } + + public function testAJobQueuedBeforeNotificationIdsExistedStillRuns(): void + { + $job = new NotificationDispatcher($this->user(1), new ConfigurableNotification(), ['database']); + $payload = serialize($job); + $revived = unserialize($payload); + $revived->notificationId = null; // as if the job was written by an older version + $revived->handle(); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testShouldSendFalseSkipsEveryChannelAndSaysSo(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack'], ['shouldSend' => false]))->send(); + $this->drain(); + + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertSame([], $this->http); + $this->assertSame(['skipped'], array_values(array_unique(array_column($this->rows('SELECT status FROM notification_deliveries'), 'status')))); + } + + public function testShouldSendViaSkipsOnlyThatChannel(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack'], ['skipChannels' => ['slack']]))->send(); + $this->drain(); + + $rows = array_column($this->rows('SELECT channel, status FROM notification_deliveries'), 'status', 'channel'); + $this->assertSame(['database' => 'sent', 'slack' => 'skipped'], $rows); + $this->assertSame([], $this->http); + } + + public function testThrottleLimitsHowOftenTheSameNotificationReachesTheSameNotifiable(): void + { + $options = ['throttle' => ['max' => 2, 'per' => 3600]]; + + for ($i = 0; $i < 4; $i++) { + $this->user(1)->notify(new ConfigurableNotification(['database'], $options))->send(); + } + $this->user(2)->notify(new ConfigurableNotification(['database'], $options))->send(); + $this->drain(); + + $this->assertSame(3, (int) $this->value('SELECT count(*) FROM notifications'), 'two for Ann, one for Bob'); + $this->assertSame(2, (int) $this->value("SELECT count(*) FROM notification_deliveries WHERE status = 'throttled'")); + $this->assertSame(2, (int) $this->value("SELECT count(*) FROM notifications WHERE notifiable_id = 1")); + } + + public function testThrottleOnlyCountsThePeriod(): void + { + $options = ['throttle' => ['max' => 1, 'per' => 60]]; + $this->user(1)->notify(new ConfigurableNotification(['database'], $options))->send(); + $this->drain(); + $this->pdo->exec("UPDATE notification_deliveries SET sent_at = '" . date('Y-m-d H:i:s', time() - 120) . "'"); + + $this->user(1)->notify(new ConfigurableNotification(['database'], $options))->send(); + $this->drain(); + + $this->assertSame(2, (int) $this->value('SELECT count(*) FROM notifications'), 'the first delivery is older than the period'); + } + + public function testThrottleIsPerChannel(): void + { + $options = ['throttle' => ['max' => 1, 'per' => 3600]]; + + for ($i = 0; $i < 2; $i++) { + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail'], $options))->send(); + } + $this->drain(); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertCount(1, $this->mails); + } + + public function testWithoutTheDeliveryTableEverythingStillWorksButThereIsNoThrottle(): void + { + $this->pdo->exec('DROP TABLE notification_deliveries'); + DeliveryLog::reset(); + + for ($i = 0; $i < 3; $i++) { + $this->user(1)->notify(new ConfigurableNotification(['database'], ['throttle' => ['max' => 1, 'per' => 3600]]))->send(); + } + $this->drain(); + + $this->assertSame(3, (int) $this->value('SELECT count(*) FROM notifications'), 'delivery is not blocked by a missing log'); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM failed_jobs')); + } + + public function testWithoutTheDeliveryTableAFailingChannelIsStillRetriedAndFails(): void + { + $this->pdo->exec('DROP TABLE notification_deliveries'); + DeliveryLog::reset(); + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + + $this->user(1)->notify(new ConfigurableNotification(['slack']))->send(); + $this->retry(3); + + $this->assertCount(3, $this->http); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM failed_jobs')); + } + + public function testTheDispatcherKeepsItsQueueSettings(): void + { + $job = new NotificationDispatcher($this->user(1), new ConfigurableNotification(), ['database']); + + // The settings come from the #[Queueable] attribute and are applied when the job is dispatched. + (function () { + $this->applyQueueableAttributes(); + })->call($job); + + $this->assertSame(3, $job->tries()); + $this->assertSame(60, $job->retryAfter()); + $this->assertSame('notifications', $job->queue()); + } + + public function testTheNotifiableComesBackFromTheQueueIntact(): void + { + $this->user(2)->notify(new ConfigurableNotification(['database']))->send(); + $this->drain(); + + $this->assertSame(2, (int) $this->value('SELECT notifiable_id FROM notifications')); + $this->assertSame('Bob', $this->user(2)->name); + } +} diff --git a/tests/Unit/EventsTest.php b/tests/Unit/EventsTest.php new file mode 100644 index 0000000..1b997f9 --- /dev/null +++ b/tests/Unit/EventsTest.php @@ -0,0 +1,162 @@ + + */ + private array $log = []; + + protected function setUp(): void + { + parent::setUp(); + $this->log = []; + } + + public function testSendingThenSentAreFiredForEachChannelInOrder(): void + { + Notification::sending(function ($notifiable, $notification, string $channel): void { + $this->log[] = "sending:$channel:" . $notifiable->id; + }); + Notification::sent(function ($notifiable, $notification, string $channel): void { + $this->log[] = "sent:$channel"; + }); + + $this->user(2)->notifyNow(new ConfigurableNotification(['database', 'mail'])); + + $this->assertSame(['sending:database:2', 'sent:database', 'sending:mail:2', 'sent:mail'], $this->log); + } + + public function testTheListenersReceiveTheNotificationAndTheNotifiable(): void + { + $seen = null; + Notification::sent(function ($notifiable, $notification, string $channel) use (&$seen): void { + $seen = [get_class($notifiable), get_class($notification), $channel]; + }); + + $this->user(1)->notifyNow(new ConfigurableNotification(['webhook'])); + + $this->assertSame([\Doppar\Notifier\Tests\Mock\Models\TestUser::class, ConfigurableNotification::class, 'webhook'], $seen); + } + + public function testASendingListenerThatReturnsFalseCancelsThatDelivery(): void + { + Notification::sending(fn ($notifiable, $notification, string $channel): bool => $channel !== 'mail'); + Notification::sent(function ($notifiable, $notification, string $channel): void { + $this->log[] = "sent:$channel"; + }); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database', 'mail'])); + + $this->assertSame(['sent:database'], $this->log); + $this->assertSame([], $this->mails, 'the cancelled delivery never happened'); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + if (static::queueAvailable()) { + $this->assertSame('cancelled', $this->value("SELECT status FROM notification_deliveries WHERE channel = 'mail'")); + } + } + + public function testOneListenerSayingNoIsEnough(): void + { + Notification::sending(fn (): bool => true); + Notification::sending(fn (): bool => false); + Notification::sending(fn () => null); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testAListenerReturningNothingDoesNotCancel(): void + { + Notification::sending(function (): void { + }); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testFailedIsFiredWithTheException(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + Notification::failed(function ($notifiable, $notification, string $channel, \Throwable $e): void { + $this->log[] = "failed:$channel:" . $e->getMessage(); + }); + Notification::sent(function ($n, $x, string $channel): void { + $this->log[] = "sent:$channel"; + }); + + try { + $this->user(1)->notifyNow(new ConfigurableNotification(['slack', 'database'])); + } catch (\RuntimeException) { + } + + $this->assertCount(2, $this->log); + $this->assertStringStartsWith('failed:slack:Slack notification failed (HTTP 500)', $this->log[0]); + $this->assertSame('sent:database', $this->log[1], 'the other channel still went out'); + } + + public function testABrokenListenerNeverStopsADelivery(): void + { + Notification::sending(function (): void { + throw new \RuntimeException('listener bug'); + }); + Notification::sent(function (): void { + throw new \RuntimeException('another listener bug'); + }); + Notification::sent(function ($n, $x, string $channel): void { + $this->log[] = "sent:$channel"; + }); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications'), 'delivered anyway'); + $this->assertSame(['sent:database'], $this->log, 'later listeners still run'); + } + + public function testASkippedChannelFiresNothing(): void + { + Notification::sending(function ($n, $x, string $channel): void { + $this->log[] = "sending:$channel"; + }); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database', 'slack'], ['skipChannels' => ['slack']])); + + $this->assertSame(['sending:database'], $this->log); + } + + public function testFlushRemovesTheListeners(): void + { + Notification::sent(function (): void { + $this->log[] = 'sent'; + }); + Notification::flushListeners(); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $this->assertSame([], $this->log); + } + + public function testEventsFireWhenAQueuedJobRuns(): void + { + $this->requireQueue(); + Notification::sent(function ($n, $x, string $channel): void { + $this->log[] = "sent:$channel"; + }); + + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + $this->assertSame([], $this->log, 'nothing yet: it is queued'); + $this->drain(); + + $this->assertSame(['sent:database'], $this->log); + } +} diff --git a/tests/Unit/FakeTest.php b/tests/Unit/FakeTest.php new file mode 100644 index 0000000..8b93aed --- /dev/null +++ b/tests/Unit/FakeTest.php @@ -0,0 +1,203 @@ +assertStringContainsString($contains, $e->getMessage()); + + return; + } + + $this->fail('The assertion should have failed.'); + } + + public function testNothingIsDeliveredWhileFaking(): void + { + $fake = Notification::fake(); + + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail']))->send(); + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + Notification::to($this->user(2))->send(new ConfigurableNotification(['slack'])); + + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM queue_jobs')); + $this->assertSame([], $this->mails); + $this->assertSame([], $this->http); + $fake->assertCount(3); + } + + public function testSendReturnsAnIdLikeTheRealThing(): void + { + Notification::fake(); + + $this->assertIsString($this->user(1)->notify(new ConfigurableNotification(['database']))->send()); + } + + public function testAssertSentToByClassAndByCallback(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new TestEmailNotification('Hi', 'There'))->send(); + + $fake->assertSentTo($this->user(1), TestEmailNotification::class); + $fake->assertSentTo($this->user(1), fn ($notification, $channels, $notifiable) => $notification->subject === 'Hi' && $channels === ['database'] && $notifiable->id === 1); + $fake->assertNotSentTo($this->user(2), TestEmailNotification::class); + $fake->assertNotSentTo($this->user(1), ConfigurableNotification::class); + $fake->assertNotSentTo($this->user(1), fn ($notification) => $notification->subject === 'Other'); + } + + public function testTheNotifiableIsRecognisedByTypeAndKeyNotByInstance(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + + $fake->assertSentTo($this->user(1), ConfigurableNotification::class); + $this->assertCount(1, $fake->sent($this->user(1))); + $this->assertCount(0, $fake->sent($this->user(2))); + } + + public function testAssertSentToFailsWithAMessage(): void + { + $fake = Notification::fake(); + + $this->assertFails(fn () => $fake->assertSentTo($this->user(1), ConfigurableNotification::class), 'was not sent'); + $this->assertFails(fn () => $fake->assertSentTo($this->user(1), fn () => true), 'matching'); + } + + public function testNumberOfTimes(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + $this->user(1)->notify(new ConfigurableNotification(['mail']))->send(); + $this->user(2)->notify(new ConfigurableNotification(['database']))->send(); + + $fake->assertSentTo($this->user(1), ConfigurableNotification::class, 2); + $fake->assertSentTimes(ConfigurableNotification::class, 3); + $this->assertFails(fn () => $fake->assertSentTo($this->user(1), ConfigurableNotification::class, 1), 'sent 2 time'); + $this->assertFails(fn () => $fake->assertSentTimes(ConfigurableNotification::class, 5), 'sent 3 time'); + } + + public function testAssertSentVia(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack']))->send(); + + $fake->assertSentVia($this->user(1), ConfigurableNotification::class, 'slack'); + $this->assertFails(fn () => $fake->assertSentVia($this->user(1), ConfigurableNotification::class, 'mail'), 'mail'); + } + + public function testChannelsChosenWithViaAreRecorded(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification(['database']))->via(['mail', 'webhook'])->send(); + + $this->assertSame(['mail', 'webhook'], $fake->sent()[0]['channels']); + } + + public function testThePreferencesStillApplyToTheRecordedChannels(): void + { + $fake = Notification::fake(); + $this->pdo->exec("UPDATE users SET muted = 'mail' WHERE id = 1"); + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail']))->send(); + + $fake->assertSentVia($this->user(1), ConfigurableNotification::class, 'database'); + $this->assertFails(fn () => $fake->assertSentVia($this->user(1), ConfigurableNotification::class, 'mail')); + } + + public function testNothingSentAndCount(): void + { + $fake = Notification::fake(); + $fake->assertNothingSent(); + $fake->assertCount(0); + + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + + $this->assertFails(fn () => $fake->assertNothingSent(), '1 were sent'); + $this->assertFails(fn () => $fake->assertCount(2), 'Expected 2'); + } + + public function testNotificationsWithNoChannelsAreNotRecorded(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification([]))->send(); + + $fake->assertNothingSent(); + } + + public function testDelayAndImmediateAreRecorded(): void + { + $fake = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification(['database']))->delay(90)->send(); + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + [$delayed, $now] = $fake->sent(); + $this->assertSame([90, false], [$delayed['delay'], $delayed['immediate']]); + $this->assertSame([0, true], [$now['delay'], $now['immediate']]); + } + + public function testBulkAndQueryBuildersAreCaptured(): void + { + $fake = Notification::fake(); + + Notification::toMany([$this->user(1), $this->user(2)])->send(new ConfigurableNotification(['database'])); + Notification::toAll(TestUser::class)->where('id', 3)->send(new ConfigurableNotification(['database'])); + Notification::schedule(new ConfigurableNotification(['database']))->to($this->user(1))->after(60); + + $fake->assertCount(4); + $fake->assertSentTo($this->user(3), ConfigurableNotification::class); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testUnfakeSendsForReal(): void + { + Notification::fake(); + Notification::unfake(); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + } + + public function testAFreshFakeStartsEmpty(): void + { + $first = Notification::fake(); + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + + $second = Notification::fake(); + + $second->assertNothingSent(); + $this->assertNotSame($first, $second); + } + + public function testNoEventsAreFiredWhileFaking(): void + { + $fired = []; + Notification::sending(function () use (&$fired): void { + $fired[] = 'sending'; + }); + Notification::fake(); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $this->assertSame([], $fired); + } +} diff --git a/tests/Unit/MailChannelTest.php b/tests/Unit/MailChannelTest.php new file mode 100644 index 0000000..e56d2f2 --- /dev/null +++ b/tests/Unit/MailChannelTest.php @@ -0,0 +1,117 @@ +send($this->user(1), new ConfigurableNotification(['mail'])); + + $this->assertCount(1, $this->mails); + $mail = $this->mails[0]; + $this->assertSame('ann@example.com', $mail['address']); + $this->assertSame('Ann', $mail['name']); + $this->assertSame('Welcome', $mail['mailable']->subject); + $this->assertStringContainsString('Hi Ann', $mail['mailable']->body); + $this->assertStringContainsString('Your account is ready.', $mail['mailable']->body); + $this->assertStringContainsString('href="https://app.example.com/start"', $mail['mailable']->body); + $this->assertStringContainsString('Open: https://app.example.com/start', (string) $mail['mailable']->textBody); + } + + public function testTheAddressCanBeRoutedAndMustBeValid(): void + { + $routed = new class extends \Doppar\Notifier\Tests\Mock\Models\TestUser { + public function routeNotificationForMail(): string + { + return 'billing@example.com'; + } + }; + $routed->id = 1; + $routed->name = 'Ann'; + (new MailChannel())->send($routed, new ConfigurableNotification(['mail'])); + $this->assertSame('billing@example.com', $this->mails[0]['address']); + + foreach ([null, '', 'not-an-email', "a@b.com\r\nBcc: x@y.com"] as $address) { + $this->pdo->prepare('UPDATE users SET email = ? WHERE id = 2')->execute([$address ?? '']); + try { + (new MailChannel())->send($this->user(2), new ConfigurableNotification(['mail'])); + $this->fail('Mail to ' . var_export($address, true) . ' must be refused.'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Mail address', $e->getMessage()); + } + } + $this->assertCount(1, $this->mails, 'nothing was sent to an invalid address'); + } + + public function testHtmlAndTextCanBeGivenDirectlyAndTheOptionsAreApplied(): void + { + $notification = new class (['mail']) extends ConfigurableNotification { + public function toMail($notifiable): array + { + return [ + 'subject' => 'Invoice', + 'html' => 'Pay', + 'text' => 'Pay', + 'replyTo' => 'billing@example.com', + 'tags' => ['invoice', 'monthly'], + 'priority' => 1, + 'cc' => ['boss@example.com'], + ]; + } + }; + + (new MailChannel())->send($this->user(1), $notification); + + $mailable = $this->mails[0]['mailable']; + $this->assertSame('Invoice', $mailable->subject); + $this->assertSame('Pay', $mailable->body); + $this->assertSame('Pay', $mailable->textBody); + $this->assertSame(1, $mailable->priority); + $this->assertSame(['boss@example.com'], $this->mails[0]['content']['cc']); + $this->assertNotEmpty($mailable->replyTo); + $this->assertNotEmpty($mailable->tags); + } + + public function testTheTemplateEscapesEverything(): void + { + $out = MailTemplate::render([ + 'greeting' => '', + 'lines' => ['', 'Fish & "chips"'], + 'action' => ['text' => 'Go', 'url' => 'https://example.com/?a=1&b=2'], + 'footer' => 'bye', + ]); + + $this->assertStringNotContainsString('', $out['text'], 'the plain text version is text, not HTML'); + } + + public function testTheActionLinkMustBeAPlainHttpAddress(): void + { + foreach (['javascript:alert(1)', 'data:text/html,x', '//evil.test', 'https://a.test/" onclick="x', "https://a.test/x y", 'ftp://a.test'] as $url) { + try { + MailTemplate::render(['action' => ['text' => 'Go', 'url' => $url]]); + $this->fail("The link $url must be refused."); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('http(s)', $e->getMessage()); + } + } + } + + public function testTheTemplateWorksWithOnlySomeParts(): void + { + $this->assertSame('Just one line.', MailTemplate::render(['lines' => 'Just one line.'])['text']); + $this->assertSame('', MailTemplate::render([])['text']); + $this->assertStringNotContainsString(' ['x'], 'action' => ['text' => 'Go']])['html'], 'no url, no button'); + } +} diff --git a/tests/Unit/MigrationTest.php b/tests/Unit/MigrationTest.php new file mode 100644 index 0000000..7ac39c0 --- /dev/null +++ b/tests/Unit/MigrationTest.php @@ -0,0 +1,35 @@ +bind('schema', fn () => new \Phaseolies\Database\Migration\Schema('default')); + + $this->pdo->exec('DROP TABLE notification_deliveries'); + $migration = require __DIR__ . '/../../src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php'; + + $migration->up(); + + $columns = array_column($this->rows('PRAGMA table_info(notification_deliveries)'), 'name'); + foreach (['delivery_key', 'notification_id', 'notification_type', 'notifiable_type', 'notifiable_id', 'channel', 'status', 'attempts', 'error', 'sent_at', 'created_at', 'updated_at'] as $column) { + $this->assertContains($column, $columns, $column); + } + + DeliveryLog::reset(); + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + $this->assertSame('sent', $this->value("SELECT status FROM notification_deliveries WHERE channel = 'database'")); + + $unique = array_filter($this->rows('PRAGMA index_list(notification_deliveries)'), fn ($i) => (int) $i['unique'] === 1); + $this->assertNotEmpty($unique, 'one row per notification and channel is enforced'); + + $migration->down(); + $this->assertSame([], $this->rows("SELECT name FROM sqlite_master WHERE name = 'notification_deliveries'")); + } +} diff --git a/tests/Unit/NotifiableTest.php b/tests/Unit/NotifiableTest.php new file mode 100644 index 0000000..6d4e0bb --- /dev/null +++ b/tests/Unit/NotifiableTest.php @@ -0,0 +1,156 @@ +pdo->prepare("INSERT INTO notifications (notifiable_type, notifiable_id, type, data, read_at, created_at) VALUES (?, ?, 'T', '{}', ?, '2026-01-01 00:00:00')"); + + for ($i = 1; $i <= $count; $i++) { + $insert->execute([TestUser::class, $userId, $read ? '2026-01-02 00:00:00' : null]); + } + } + + public function testNotificationsBelongToTheEntityWithNobodySignedIn(): void + { + $this->give(1, 2); + $this->give(2, 5); + + $this->assertCount(2, $this->user(1)->notifications()); + $this->assertCount(5, $this->user(2)->notifications()); + $this->assertCount(0, $this->user(3)->notifications()); + } + + public function testUnreadReadAndTheUnreadCount(): void + { + $this->give(1, 3); + $this->give(1, 2, true); + $this->give(2, 4); + + $ann = $this->user(1); + + $this->assertCount(3, $ann->unreadNotifications()); + $this->assertCount(2, $ann->readNotifications()); + $this->assertSame(3, $ann->unreadNotificationsCount()); + $this->assertSame(4, $this->user(2)->unreadNotificationsCount()); + $this->assertSame(0, $this->user(3)->unreadNotificationsCount()); + } + + public function testNewestFirst(): void + { + $this->give(1, 3); + + $ids = array_map(fn ($n) => (int) $n->id, $this->user(1)->notifications()->all()); + + $this->assertSame([3, 2, 1], $ids); + } + + public function testMarkAllAsReadOnlyTouchesTheEntity(): void + { + $this->give(1, 3); + $this->give(2, 2); + + $this->assertSame(3, $this->user(1)->markNotificationsAsRead()); + + $this->assertSame(0, $this->user(1)->unreadNotificationsCount()); + $this->assertSame(2, $this->user(2)->unreadNotificationsCount(), 'somebody else is untouched'); + $this->assertSame(0, $this->user(1)->markNotificationsAsRead(), 'nothing left to mark'); + } + + public function testMarkOneAsReadIsLimitedToTheEntitysOwn(): void + { + $this->give(1, 1); + $this->give(2, 1); + $theirs = (int) $this->value("SELECT id FROM notifications WHERE notifiable_id = 2"); + $mine = (int) $this->value("SELECT id FROM notifications WHERE notifiable_id = 1"); + + $this->assertFalse($this->user(1)->markNotificationAsRead($theirs), 'an id of another user is refused'); + $this->assertNull($this->value("SELECT read_at FROM notifications WHERE id = $theirs") ?: null); + + $this->assertTrue($this->user(1)->markNotificationAsRead($mine)); + $this->assertNotNull($this->value("SELECT read_at FROM notifications WHERE id = $mine") ?: null); + $this->assertFalse($this->user(1)->markNotificationAsRead(999), 'no such notification'); + } + + public function testDeleteOneAndDeleteRead(): void + { + $this->give(1, 2); + $this->give(1, 3, true); + $this->give(2, 1, true); + $unread = (int) $this->value("SELECT id FROM notifications WHERE notifiable_id = 1 AND read_at IS NULL LIMIT 1"); + $theirs = (int) $this->value("SELECT id FROM notifications WHERE notifiable_id = 2"); + + $this->assertFalse($this->user(1)->deleteNotification($theirs), 'not allowed to delete another user\'s'); + $this->assertTrue($this->user(1)->deleteNotification($unread)); + $this->assertFalse($this->user(1)->deleteNotification($unread), 'already gone'); + + $this->assertSame(3, $this->user(1)->deleteReadNotifications()); + $this->assertSame(1, $this->user(1)->unreadNotificationsCount()); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications WHERE notifiable_id = 2')); + } + + public function testAnEntityThatIsNotSavedHasNoNotificationsAndTouchesNothing(): void + { + $this->give(1, 2); + $unsaved = new TestUser(); + + $this->assertCount(0, $unsaved->notifications()); + $this->assertSame(0, $unsaved->unreadNotificationsCount()); + $this->assertSame(0, $unsaved->markNotificationsAsRead()); + $this->assertSame(0, $unsaved->deleteReadNotifications()); + $this->assertSame(2, $this->user(1)->unreadNotificationsCount()); + } + + public function testTheTypeIsPartOfTheScope(): void + { + $this->pdo->prepare("INSERT INTO notifications (notifiable_type, notifiable_id, type, data, created_at) VALUES (?, 1, 'T', '{}', '2026-01-01 00:00:00')")->execute(['App\\Other']); + + $this->assertCount(0, $this->user(1)->notifications(), 'another model with the same id is not this entity'); + } + + public function testDeliveriesOfTheEntity(): void + { + $this->requireQueue(); + + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + $this->user(2)->notifyNow(new ConfigurableNotification(['database'])); + + $deliveries = $this->user(1)->notificationDeliveries(); + + $this->assertCount(1, $deliveries); + $this->assertSame('database', $deliveries->first()->channel); + $this->assertTrue($deliveries->first()->wasSent()); + } + + public function testThePayloadIsDecoded(): void + { + $this->user(1)->notifyNow(new ConfigurableNotification(['database'])); + + $row = $this->user(1)->notifications()->first(); + + $this->assertInstanceOf(DatabaseNotification::class, $row); + $this->assertSame('Hello', $row->payload()['subject']); + } + + public function testPruneDeletesOldReadNotificationsOnly(): void + { + $now = date('Y-m-d H:i:s'); + $old = date('Y-m-d H:i:s', time() - 40 * 86400); + foreach ([['old read', $old, "'$old'"], ['old unread', $old, 'NULL'], ['new read', $now, "'$now'"]] as [$type, $created, $read]) { + $this->pdo->exec("INSERT INTO notifications (notifiable_type, notifiable_id, type, data, read_at, created_at) VALUES ('x', 1, '$type', '{}', $read, '$created')"); + } + + $this->assertSame(1, DatabaseNotification::prune(30)); + $this->assertSame(['new read', 'old unread'], array_column($this->rows('SELECT type FROM notifications ORDER BY type'), 'type')); + + $this->assertSame(1, DatabaseNotification::prune(30, false), 'with everything: the old unread one goes too'); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + } +} diff --git a/tests/Unit/PipelineTest.php b/tests/Unit/PipelineTest.php new file mode 100644 index 0000000..beb8044 --- /dev/null +++ b/tests/Unit/PipelineTest.php @@ -0,0 +1,164 @@ +requireQueue(); + } + + public function testEachChannelIsItsOwnJob(): void + { + $id = $this->user(1)->notify(new ConfigurableNotification(['database', 'mail', 'slack']))->send(); + + $this->assertSame(3, Queue::size('notifications')); + $this->assertSame(3, (int) $this->value('SELECT count(*) FROM queue_jobs')); + $this->assertIsString($id); + $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $id); + } + + public function testTheDeliveriesOfOneSendShareTheReturnedId(): void + { + $id = $this->user(1)->notify(new ConfigurableNotification(['database', 'mail', 'webhook']))->send(); + $this->drain(); + + $rows = $this->rows("SELECT notification_id, channel, status FROM notification_deliveries ORDER BY channel"); + $this->assertSame(['database', 'mail', 'webhook'], array_column($rows, 'channel')); + $this->assertSame([$id], array_values(array_unique(array_column($rows, 'notification_id')))); + $this->assertSame(['sent'], array_values(array_unique(array_column($rows, 'status')))); + } + + public function testEveryDispatchGetsADifferentId(): void + { + $a = $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + $b = $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + + $this->assertNotSame($a, $b); + } + + public function testNoChannelsMeansNothingIsQueued(): void + { + $this->assertNull($this->user(1)->notify(new ConfigurableNotification([]))->send()); + $this->assertNull($this->user(1)->notify(new ConfigurableNotification(['database']))->via([])->send()); + $this->assertSame(0, Queue::size('notifications')); + } + + public function testRepeatedChannelsAreQueuedOnce(): void + { + $this->user(1)->notify(new ConfigurableNotification(['slack', 'slack', 'slack']))->send(); + + $this->assertSame(1, Queue::size('notifications')); + } + + public function testSendingTwiceFromTheSameBuilderQueuesOnce(): void + { + $pipeline = $this->user(1)->notify(new ConfigurableNotification(['database'])); + $pipeline->send(); + + $this->assertNull($pipeline->send()); + $this->assertSame(1, Queue::size('notifications')); + } + + public function testANotificationThatIsNeverSentExplicitlyGoesOutWhenTheBuilderIsDropped(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database'])); + + $this->assertSame(1, Queue::size('notifications')); + } + + public function testADelayDelaysEveryChannel(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack']))->delay(300)->send(); + + foreach ($this->rows('SELECT available_at FROM queue_jobs') as $row) { + $this->assertEqualsWithDelta(time() + 300, (int) $row['available_at'], 5); + } + $this->assertSame(0, $this->drain(), 'nothing is ready yet'); + } + + public function testTheNotificationsOwnDelayIsUsedWhenNoneIsGiven(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database'], ['delay' => 600]))->send(); + + $this->assertEqualsWithDelta(time() + 600, (int) $this->value('SELECT available_at FROM queue_jobs'), 5); + } + + public function testViaChoosesTheChannelsAndBeatsTheNotification(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail', 'slack']))->via(['webhook'])->send(); + $this->drain(); + + $this->assertSame(['webhook'], array_column($this->rows('SELECT channel FROM notification_deliveries'), 'channel')); + $this->assertCount(1, $this->http); + $this->assertSame([], $this->mails); + } + + public function testThePreferencesOfTheNotifiableFilterTheNotificationsOwnChannels(): void + { + $this->pdo->exec("UPDATE users SET muted = 'slack,mail' WHERE id = 1"); + + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail', 'slack', 'webhook']))->send(); + $this->drain(); + + $this->assertSame(['database', 'webhook'], array_column($this->rows('SELECT channel FROM notification_deliveries ORDER BY channel'), 'channel')); + $this->assertSame([], $this->mails, 'the person switched mail off'); + } + + public function testExplicitViaIsNotFilteredByPreferences(): void + { + $this->pdo->exec("UPDATE users SET muted = 'mail' WHERE id = 1"); + + $this->user(1)->notify(new ConfigurableNotification(['database']))->via(['mail'])->send(); + $this->drain(); + + $this->assertCount(1, $this->mails, 'asking for a channel by name is a decision, not a default'); + } + + public function testAnEverythingMutedPersonReceivesNothing(): void + { + $this->pdo->exec("UPDATE users SET muted = 'database,mail' WHERE id = 1"); + + $this->assertNull($this->user(1)->notify(new ConfigurableNotification(['database', 'mail']))->send()); + $this->assertSame(0, Queue::size('notifications')); + } + + public function testNotifyNowDeliversAtOnceWithoutTheQueue(): void + { + $this->user(1)->notifyNow(new ConfigurableNotification(['database', 'mail'])); + + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM queue_jobs')); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertCount(1, $this->mails); + } + + public function testNotifyNowStillTriesEveryChannelWhenOneFailsAndThenThrows(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + + try { + $this->user(1)->notifyNow(new ConfigurableNotification(['slack', 'database', 'mail'])); + $this->fail('The slack failure must reach the caller.'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Slack notification failed', $e->getMessage()); + } + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications'), 'database was still delivered'); + $this->assertCount(1, $this->mails, 'and so was mail'); + } + + public function testAnUnknownChannelIsRejectedWhenItRuns(): void + { + $this->user(1)->notify(new ConfigurableNotification(['carrier-pigeon']))->send(); + $this->drain(); + + $this->assertSame('failed', $this->value("SELECT status FROM notification_deliveries WHERE channel = 'carrier-pigeon'")); + $this->assertStringContainsString('not supported', (string) $this->value("SELECT error FROM notification_deliveries WHERE channel = 'carrier-pigeon'")); + } +} diff --git a/tests/Unit/PruneCommandTest.php b/tests/Unit/PruneCommandTest.php new file mode 100644 index 0000000..dba950d --- /dev/null +++ b/tests/Unit/PruneCommandTest.php @@ -0,0 +1,53 @@ +pdo->prepare("INSERT INTO notifications (notifiable_type, notifiable_id, type, data, read_at, created_at) VALUES ('x', 1, ?, '{}', ?, ?)"); + + foreach ([['old-read', $old, $old], ['old-unread', null, $old], ['new-read', $now, $now], ['new-unread', null, $now]] as [$type, $read, $created]) { + $insert->execute([$type, $read, $created]); + } + } + + public function testItDeletesOldReadNotificationsByDefault(): void + { + $this->seed(); + $command = (new SpyPruneCommand())->withOptions(['days' => '90']); + + $this->assertSame(0, $command->handle()); + + $this->assertSame(['new-read', 'new-unread', 'old-unread'], array_column($this->rows('SELECT type FROM notifications ORDER BY type'), 'type')); + $this->assertStringContainsString('1 notification(s)', $command->lines[0]); + } + + public function testAllIncludesUnread(): void + { + $this->seed(); + + (new SpyPruneCommand())->withOptions(['days' => '90', 'all' => true])->handle(); + + $this->assertSame(['new-read', 'new-unread'], array_column($this->rows('SELECT type FROM notifications ORDER BY type'), 'type')); + } + + public function testDaysIsValidated(): void + { + $this->seed(); + + foreach (['abc', '-5', ''] as $days) { + $command = (new SpyPruneCommand())->withOptions(['days' => $days]); + + $this->assertSame(1, $command->handle(), "days=$days"); + $this->assertNotEmpty($command->errors); + } + $this->assertSame(4, (int) $this->value('SELECT count(*) FROM notifications'), 'nothing was deleted'); + } +} diff --git a/tests/Unit/QueueFeaturesTest.php b/tests/Unit/QueueFeaturesTest.php new file mode 100644 index 0000000..a4967d7 --- /dev/null +++ b/tests/Unit/QueueFeaturesTest.php @@ -0,0 +1,110 @@ +requireQueueFeatures(); + } + + public function testAHigherPriorityNotificationIsDeliveredFirst(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database'], ['priority' => 0]))->send(); + $this->user(2)->notify(new ConfigurableNotification(['database'], ['priority' => 50]))->send(); + $this->user(3)->notify(new ConfigurableNotification(['database'], ['priority' => -10]))->send(); + + $this->assertSame([50, 0, -10], array_map('intval', array_column($this->rows('SELECT priority FROM queue_jobs ORDER BY priority DESC'), 'priority'))); + + $worker = $this->worker(); + $order = []; + for ($i = 0; $i < 3; $i++) { + $worker->runNextJob('notifications'); + $order[] = (int) $this->value('SELECT notifiable_id FROM notifications ORDER BY id DESC LIMIT 1'); + } + + $this->assertSame([2, 1, 3], $order); + } + + public function testThePriorityIsKeptWithinTheLimits(): void + { + $job = new NotificationDispatcher($this->user(1), new ConfigurableNotification(['database'], ['priority' => 9999]), ['database']); + $this->assertSame(100, $job->priority()); + + $job = new NotificationDispatcher($this->user(1), new ConfigurableNotification(['database'], ['priority' => -9999]), ['database']); + $this->assertSame(-100, $job->priority()); + } + + public function testAUniqueNotificationIsQueuedOnlyOnceWhileItWaits(): void + { + $notification = fn () => new ConfigurableNotification(['database'], ['uniqueId' => 'order-42']); + + $first = $this->user(1)->notify($notification())->send(); + $second = $this->user(1)->notify($notification())->send(); + + $this->assertIsString($first); + $this->assertIsString($second, 'the send still reports an id, the duplicate job was simply not queued'); + $this->assertSame(1, Queue::size('notifications')); + + $this->user(2)->notify($notification())->send(); + $this->assertSame(2, Queue::size('notifications'), 'another person is not a duplicate'); + + $this->drain(); + $this->user(1)->notify($notification())->send(); + $this->assertSame(1, Queue::size('notifications'), 'once delivered, it can be queued again'); + } + + public function testUniqueIsPerChannel(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail'], ['uniqueId' => 'x']))->send(); + + $this->assertSame(2, Queue::size('notifications'), 'database and mail are separate jobs, so both are queued'); + } + + public function testWithoutAUniqueIdDuplicatesAreAllowed(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + $this->user(1)->notify(new ConfigurableNotification(['database']))->send(); + + $this->assertSame(2, Queue::size('notifications')); + } + + public function testTheBackoffOfTheNotificationSetsTheRetryDelay(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + $this->user(1)->notify(new ConfigurableNotification(['slack'], ['backoff' => [5, 30, 120]]))->send(); + + $this->drain(); + $this->assertEqualsWithDelta(time() + 5, (int) $this->value('SELECT available_at FROM queue_jobs'), 5, 'first retry after 5 seconds'); + + $this->releaseQueuedJobs(); + $this->drain(); + $this->assertEqualsWithDelta(time() + 30, (int) $this->value('SELECT available_at FROM queue_jobs'), 5, 'second retry after 30 seconds'); + } + + public function testWithoutABackoffTheDispatchersRetryAfterApplies(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + $this->user(1)->notify(new ConfigurableNotification(['slack']))->send(); + + $this->drain(); + + $this->assertEqualsWithDelta(time() + 60, (int) $this->value('SELECT available_at FROM queue_jobs'), 5); + } + + public function testANotificationCanChooseItsQueueConnection(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database'], ['connection' => 'memory']))->send(); + + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM queue_jobs'), 'not in the database queue'); + $this->assertSame(1, Queue::size('notifications', 'memory')); + $this->assertSame(0, Queue::size('notifications')); + } +} diff --git a/tests/Unit/RedisQueueTest.php b/tests/Unit/RedisQueueTest.php new file mode 100644 index 0000000..e5a4f8e --- /dev/null +++ b/tests/Unit/RedisQueueTest.php @@ -0,0 +1,137 @@ +requireQueueFeatures(); + + if (!class_exists(\Predis\Client::class)) { + $this->markTestSkipped('predis/predis is not installed.'); + } + + $url = getenv('QUEUE_TEST_REDIS_URL') ?: 'redis://127.0.0.1:6379'; + $this->redis = new \Predis\Client($url, ['parameters' => ['database' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15)]]); + + try { + $this->redis->ping(); + } catch (\Throwable $e) { + $this->markTestSkipped('Redis is not reachable: ' . $e->getMessage()); + } + + $this->prefix = '{ntest_' . bin2hex(random_bytes(6)) . '}'; + $this->now = time(); + $this->queue = new QueueManager([ + 'default' => 'redis', + 'connections' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => $url, + 'prefix' => $this->prefix, + 'lease' => 90, + 'options' => ['parameters' => ['database' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15)]], + ], + ], + ], fn (): int => $this->now); + } + + protected function tearDown(): void + { + if ($this->redis !== null && $this->prefix !== '') { + $keys = $this->redis->keys($this->prefix . '*'); + + if ($keys !== []) { + $this->redis->del($keys); + } + } + + parent::tearDown(); + } + + /** + * Let the clock run past the retry delays and work off what is ready + * + * @param int $seconds + * @return int + */ + private function later(int $seconds = 61): int + { + $this->now += $seconds; + + return $this->drain(); + } + + public function testEachChannelIsOneJobOnRedis(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database', 'mail', 'webhook']))->send(); + + $this->assertSame(3, Queue::size('notifications')); + $this->assertSame(0, (int) $this->value('SELECT count(*) FROM queue_jobs'), 'nothing went to the database queue'); + + $this->assertSame(3, $this->drain()); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + $this->assertCount(1, $this->mails); + $this->assertCount(1, $this->http); + $this->assertSame(0, Queue::size('notifications')); + } + + public function testAFailingChannelIsRetriedAloneAndThenFails(): void + { + $this->httpAnswer = ['status' => 500, 'body' => 'down', 'error' => '']; + $this->user(1)->notify(new ConfigurableNotification(['database', 'slack']))->send(); + + $this->drain(); + $this->assertSame(1, Queue::size('notifications'), 'only the slack job is waiting for its retry'); + + $this->later(); + $this->later(); + + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications'), 'one database row'); + $this->assertCount(3, $this->http); + $this->assertSame(1, Queue::connection('redis')->countFailed()); + $this->assertSame(0, Queue::size('notifications')); + } + + public function testPriorityOrdersTheDeliveries(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database'], ['priority' => 0]))->send(); + $this->user(2)->notify(new ConfigurableNotification(['database'], ['priority' => 80]))->send(); + + $this->drain(); + + $this->assertSame([2, 1], array_map('intval', array_column($this->rows('SELECT notifiable_id FROM notifications ORDER BY id'), 'notifiable_id'))); + } + + public function testAUniqueNotificationIsQueuedOnce(): void + { + for ($i = 0; $i < 3; $i++) { + $this->user(1)->notify(new ConfigurableNotification(['database'], ['uniqueId' => 'same']))->send(); + } + + $this->assertSame(1, Queue::size('notifications')); + } + + public function testADelayedNotificationWaitsForTheClock(): void + { + $this->user(1)->notify(new ConfigurableNotification(['database']))->delay(300)->send(); + + $this->assertSame(0, $this->drain()); + $this->assertSame(0, $this->later(100), 'still early'); + $this->assertSame(1, $this->later(250), 'now it is due'); + $this->assertSame(1, (int) $this->value('SELECT count(*) FROM notifications')); + } +} From 764fc1815fe77b7ddf10b574fdf80eacf2f01171 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Wed, 7 Oct 2026 18:51:12 +0600 Subject: [PATCH 2/2] fix: static analysis error: --- src/Models/NotificationDelivery.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Models/NotificationDelivery.php b/src/Models/NotificationDelivery.php index 5e0658a..00435b7 100644 --- a/src/Models/NotificationDelivery.php +++ b/src/Models/NotificationDelivery.php @@ -4,6 +4,9 @@ use Phaseolies\Database\Entity\Model; +/** + * @property string $status + */ class NotificationDelivery extends Model { /**