92 - Add in-app notification infrastructure with model, service, and bell component
This commit is contained in:
parent
ec09711a6f
commit
4feab96765
11 changed files with 663 additions and 0 deletions
10
app/Enums/NotificationSeverityEnum.php
Normal file
10
app/Enums/NotificationSeverityEnum.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationSeverityEnum: string
|
||||
{
|
||||
case INFO = 'info';
|
||||
case WARNING = 'warning';
|
||||
case ERROR = 'error';
|
||||
}
|
||||
21
app/Enums/NotificationTypeEnum.php
Normal file
21
app/Enums/NotificationTypeEnum.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationTypeEnum: string
|
||||
{
|
||||
case GENERAL = 'general';
|
||||
case FEED_STALE = 'feed_stale';
|
||||
case PUBLISH_FAILED = 'publish_failed';
|
||||
case CREDENTIAL_EXPIRED = 'credential_expired';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GENERAL => 'General',
|
||||
self::FEED_STALE => 'Feed Stale',
|
||||
self::PUBLISH_FAILED => 'Publish Failed',
|
||||
self::CREDENTIAL_EXPIRED => 'Credential Expired',
|
||||
};
|
||||
}
|
||||
}
|
||||
42
app/Livewire/NotificationBell.php
Normal file
42
app/Livewire/NotificationBell.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
public function markAsRead(int $id): void
|
||||
{
|
||||
Notification::findOrFail($id)->markAsRead();
|
||||
}
|
||||
|
||||
public function markAllAsRead(): void
|
||||
{
|
||||
Notification::markAllAsRead();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function unreadCount(): int
|
||||
{
|
||||
return Notification::unread()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Notification>
|
||||
*/
|
||||
#[Computed]
|
||||
public function notifications(): Collection
|
||||
{
|
||||
return Notification::recent()->get();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.notification-bell');
|
||||
}
|
||||
}
|
||||
92
app/Models/Notification.php
Normal file
92
app/Models/Notification.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use Database\Factories\NotificationFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property NotificationTypeEnum $type
|
||||
* @property NotificationSeverityEnum $severity
|
||||
* @property string $title
|
||||
* @property string $message
|
||||
* @property array<string, mixed>|null $data
|
||||
* @property string|null $notifiable_type
|
||||
* @property int|null $notifiable_id
|
||||
* @property Carbon|null $read_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class Notification extends Model
|
||||
{
|
||||
/** @use HasFactory<NotificationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'severity',
|
||||
'title',
|
||||
'message',
|
||||
'data',
|
||||
'notifiable_type',
|
||||
'notifiable_id',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'type' => NotificationTypeEnum::class,
|
||||
'severity' => NotificationSeverityEnum::class,
|
||||
'data' => 'array',
|
||||
'read_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function notifiable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->read_at !== null;
|
||||
}
|
||||
|
||||
public function markAsRead(): void
|
||||
{
|
||||
$this->update(['read_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Notification> $query
|
||||
* @return Builder<Notification>
|
||||
*/
|
||||
public function scopeUnread(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('read_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Notification> $query
|
||||
* @return Builder<Notification>
|
||||
*/
|
||||
public function scopeRecent(Builder $query): Builder
|
||||
{
|
||||
return $query->latest()->limit(50);
|
||||
}
|
||||
|
||||
public static function markAllAsRead(): void
|
||||
{
|
||||
static::unread()->update(['read_at' => now()]);
|
||||
}
|
||||
}
|
||||
69
app/Services/Notification/NotificationService.php
Normal file
69
app/Services/Notification/NotificationService.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Notification;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function send(
|
||||
NotificationTypeEnum $type,
|
||||
NotificationSeverityEnum $severity,
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return Notification::create([
|
||||
'type' => $type,
|
||||
'severity' => $severity,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'data' => $data ?: null,
|
||||
'notifiable_type' => $notifiable?->getMorphClass(),
|
||||
'notifiable_id' => $notifiable?->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function info(
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::INFO, $title, $message, $notifiable, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function warning(
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::WARNING, $title, $message, $notifiable, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function error(
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::ERROR, $title, $message, $notifiable, $data);
|
||||
}
|
||||
}
|
||||
51
database/factories/NotificationFactory.php
Normal file
51
database/factories/NotificationFactory.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Notification>
|
||||
*/
|
||||
class NotificationFactory extends Factory
|
||||
{
|
||||
protected $model = Notification::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'type' => fake()->randomElement(NotificationTypeEnum::cases()),
|
||||
'severity' => fake()->randomElement(NotificationSeverityEnum::cases()),
|
||||
'title' => fake()->sentence(3),
|
||||
'message' => fake()->sentence(),
|
||||
'data' => null,
|
||||
'read_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function read(): static
|
||||
{
|
||||
return $this->state(['read_at' => now()]);
|
||||
}
|
||||
|
||||
public function unread(): static
|
||||
{
|
||||
return $this->state(['read_at' => null]);
|
||||
}
|
||||
|
||||
public function severity(NotificationSeverityEnum $severity): static
|
||||
{
|
||||
return $this->state(['severity' => $severity]);
|
||||
}
|
||||
|
||||
public function type(NotificationTypeEnum $type): static
|
||||
{
|
||||
return $this->state(['type' => $type]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('type');
|
||||
$table->string('severity');
|
||||
$table->string('title');
|
||||
$table->text('message');
|
||||
$table->json('data')->nullable();
|
||||
$table->string('notifiable_type')->nullable();
|
||||
$table->unsignedBigInteger('notifiable_id')->nullable();
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('read_at');
|
||||
$table->index('created_at');
|
||||
$table->index(['notifiable_type', 'notifiable_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
|
|
@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
|||
<nav class="mt-5 flex-1 px-2 bg-white">
|
||||
@include('layouts.navigation-items')
|
||||
</nav>
|
||||
<div class="flex-shrink-0 px-4 py-3 border-t border-gray-200">
|
||||
<livewire:notification-bell />
|
||||
</div>
|
||||
<div class="flex-shrink-0 p-4 border-t border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring
|
|||
</button>
|
||||
<div class="flex-1 px-4 flex justify-between items-center">
|
||||
<h1 class="text-lg font-medium text-gray-900">FFR</h1>
|
||||
<livewire:notification-bell />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
90
resources/views/livewire/notification-bell.blade.php
Normal file
90
resources/views/livewire/notification-bell.blade.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<div class="relative" x-data="{ open: false }">
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="relative p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
@if ($this->unreadCount > 0)
|
||||
<span class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-500 rounded-full">
|
||||
{{ $this->unreadCount > 99 ? '99+' : $this->unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
@click.away="open = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Notifications</h3>
|
||||
@if ($this->unreadCount > 0)
|
||||
<button
|
||||
wire:click="markAllAsRead"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
@forelse ($this->notifications as $notification)
|
||||
<div
|
||||
class="px-4 py-3 border-b border-gray-50 last:border-b-0 {{ $notification->isRead() ? 'bg-white' : 'bg-blue-50' }}"
|
||||
wire:key="notification-{{ $notification->id }}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
@if ($notification->severity === \App\Enums\NotificationSeverityEnum::ERROR)
|
||||
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
@elseif ($notification->severity === \App\Enums\NotificationSeverityEnum::WARNING)
|
||||
<svg class="h-5 w-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">{{ $notification->title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ $notification->message }}</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-400">{{ $notification->created_at->diffForHumans() }}</span>
|
||||
@unless ($notification->isRead())
|
||||
<button
|
||||
wire:click="markAsRead({{ $notification->id }})"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">No notifications</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
85
tests/Feature/Livewire/NotificationBellTest.php
Normal file
85
tests/Feature/Livewire/NotificationBellTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\NotificationBell;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationBellTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_renders_successfully(): void
|
||||
{
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Notifications');
|
||||
}
|
||||
|
||||
public function test_it_shows_unread_count_badge(): void
|
||||
{
|
||||
Notification::factory()->count(3)->unread()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('3');
|
||||
}
|
||||
|
||||
public function test_it_hides_badge_when_no_unread(): void
|
||||
{
|
||||
Notification::factory()->count(2)->read()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertDontSee('Mark all as read');
|
||||
}
|
||||
|
||||
public function test_it_shows_empty_state_when_no_notifications(): void
|
||||
{
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('No notifications');
|
||||
}
|
||||
|
||||
public function test_mark_as_read(): void
|
||||
{
|
||||
$notification = Notification::factory()->unread()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->call('markAsRead', $notification->id);
|
||||
|
||||
$this->assertTrue($notification->fresh()->isRead());
|
||||
}
|
||||
|
||||
public function test_mark_all_as_read(): void
|
||||
{
|
||||
Notification::factory()->count(3)->unread()->create();
|
||||
|
||||
$this->assertEquals(3, Notification::unread()->count());
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->call('markAllAsRead');
|
||||
|
||||
$this->assertEquals(0, Notification::unread()->count());
|
||||
}
|
||||
|
||||
public function test_it_displays_notification_title_and_message(): void
|
||||
{
|
||||
Notification::factory()->create([
|
||||
'title' => 'Test Notification Title',
|
||||
'message' => 'Test notification message body',
|
||||
]);
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('Test Notification Title')
|
||||
->assertSee('Test notification message body');
|
||||
}
|
||||
|
||||
public function test_it_caps_badge_at_99_plus(): void
|
||||
{
|
||||
Notification::factory()->count(100)->unread()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('99+');
|
||||
}
|
||||
}
|
||||
166
tests/Feature/NotificationTest.php
Normal file
166
tests/Feature/NotificationTest.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new NotificationService;
|
||||
}
|
||||
|
||||
public function test_send_creates_notification_with_all_fields(): void
|
||||
{
|
||||
$notification = $this->service->send(
|
||||
NotificationTypeEnum::FEED_STALE,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
'Feed is stale',
|
||||
'Feed "VRT" has not returned new articles in 24 hours.',
|
||||
data: ['hours_stale' => 24],
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => 'feed_stale',
|
||||
'severity' => 'warning',
|
||||
'title' => 'Feed is stale',
|
||||
]);
|
||||
$this->assertEquals(['hours_stale' => 24], $notification->data);
|
||||
$this->assertNull($notification->read_at);
|
||||
}
|
||||
|
||||
public function test_info_convenience_method(): void
|
||||
{
|
||||
$notification = $this->service->info('Test title', 'Test message');
|
||||
|
||||
$this->assertEquals(NotificationTypeEnum::GENERAL, $notification->type);
|
||||
$this->assertEquals(NotificationSeverityEnum::INFO, $notification->severity);
|
||||
}
|
||||
|
||||
public function test_warning_convenience_method(): void
|
||||
{
|
||||
$notification = $this->service->warning('Warning title', 'Warning message');
|
||||
|
||||
$this->assertEquals(NotificationSeverityEnum::WARNING, $notification->severity);
|
||||
}
|
||||
|
||||
public function test_error_convenience_method(): void
|
||||
{
|
||||
$notification = $this->service->error('Error title', 'Error message');
|
||||
|
||||
$this->assertEquals(NotificationSeverityEnum::ERROR, $notification->severity);
|
||||
}
|
||||
|
||||
public function test_mark_as_read(): void
|
||||
{
|
||||
$notification = Notification::factory()->unread()->create();
|
||||
|
||||
$this->assertFalse($notification->isRead());
|
||||
|
||||
$notification->markAsRead();
|
||||
|
||||
$this->assertTrue($notification->fresh()->isRead());
|
||||
$this->assertNotNull($notification->fresh()->read_at);
|
||||
}
|
||||
|
||||
public function test_mark_all_as_read(): void
|
||||
{
|
||||
Notification::factory()->count(3)->unread()->create();
|
||||
Notification::factory()->count(2)->read()->create();
|
||||
|
||||
$this->assertEquals(3, Notification::unread()->count());
|
||||
|
||||
Notification::markAllAsRead();
|
||||
|
||||
$this->assertEquals(0, Notification::unread()->count());
|
||||
}
|
||||
|
||||
public function test_unread_scope(): void
|
||||
{
|
||||
Notification::factory()->count(2)->unread()->create();
|
||||
Notification::factory()->count(3)->read()->create();
|
||||
|
||||
$this->assertEquals(2, Notification::unread()->count());
|
||||
}
|
||||
|
||||
public function test_recent_scope_limits_to_50(): void
|
||||
{
|
||||
Notification::factory()->count(60)->create();
|
||||
|
||||
$this->assertCount(50, Notification::recent()->get());
|
||||
}
|
||||
|
||||
public function test_enums_cast_correctly(): void
|
||||
{
|
||||
$notification = Notification::factory()->create([
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED,
|
||||
'severity' => NotificationSeverityEnum::ERROR,
|
||||
]);
|
||||
|
||||
$fresh = Notification::find($notification->id);
|
||||
$this->assertInstanceOf(NotificationTypeEnum::class, $fresh->type);
|
||||
$this->assertInstanceOf(NotificationSeverityEnum::class, $fresh->severity);
|
||||
$this->assertEquals(NotificationTypeEnum::PUBLISH_FAILED, $fresh->type);
|
||||
$this->assertEquals(NotificationSeverityEnum::ERROR, $fresh->severity);
|
||||
}
|
||||
|
||||
public function test_notifiable_morph_to_relationship(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
|
||||
$notification = $this->service->send(
|
||||
NotificationTypeEnum::FEED_STALE,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
'Feed stale',
|
||||
'No new articles',
|
||||
notifiable: $feed,
|
||||
);
|
||||
|
||||
$fresh = Notification::find($notification->id);
|
||||
$this->assertInstanceOf(Feed::class, $fresh->notifiable);
|
||||
$this->assertEquals($feed->id, $fresh->notifiable->id);
|
||||
}
|
||||
|
||||
public function test_notification_without_notifiable(): void
|
||||
{
|
||||
$notification = $this->service->info('General notice', 'Something happened');
|
||||
|
||||
$this->assertNull($notification->notifiable_type);
|
||||
$this->assertNull($notification->notifiable_id);
|
||||
$this->assertNull($notification->notifiable);
|
||||
}
|
||||
|
||||
public function test_notification_type_enum_labels(): void
|
||||
{
|
||||
$this->assertEquals('General', NotificationTypeEnum::GENERAL->label());
|
||||
$this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->label());
|
||||
$this->assertEquals('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label());
|
||||
$this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label());
|
||||
}
|
||||
|
||||
public function test_data_stores_null_when_empty(): void
|
||||
{
|
||||
$notification = $this->service->info('Title', 'Message');
|
||||
|
||||
$this->assertNull($notification->data);
|
||||
}
|
||||
|
||||
public function test_data_stores_array_when_provided(): void
|
||||
{
|
||||
$notification = $this->service->info('Title', 'Message', data: ['key' => 'value']);
|
||||
|
||||
$this->assertEquals(['key' => 'value'], $notification->data);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue