feat: add notification infrastructure with database channel, enum, and notification classes (Story 3.1)

Set up Laravel notification system with NotificationType enum (5 types),
NudgeNotification, DocumentUploadedNotification, and DeclarationOverdueNotification
classes with database + mail channels. Add email templates, infrastructure tests,
and fix existing NotificationController tests for workspace compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:26:03 +01:00
parent 6956f7bf95
commit 1ab3cfc445
10 changed files with 731 additions and 9 deletions

View File

@@ -0,0 +1,203 @@
<?php
use App\Enums\NotificationType;
use App\Enums\WorkspaceUserRole;
use App\Mail\NudgeNotificationMail;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\DeclarationOverdueNotification;
use App\Notifications\DocumentUploadedNotification;
use App\Notifications\NudgeNotification;
use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert;
function setupNotificationUser(string $role = 'owner'): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => [],
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
// ── NotificationType Enum ──────────────────────────────────
test('notification type enum has all expected values', function () {
expect(NotificationType::getValues())->toContain('nudge')
->toContain('declaration_overdue')
->toContain('document_uploaded')
->toContain('bulk_notification')
->toContain('status_changed');
expect(NotificationType::getValues())->toHaveCount(5);
});
// ── NudgeNotification ──────────────────────────────────────
test('nudge notification sends via database and mail channels', function () {
Notification::fake();
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$recipient = User::factory()->create();
$workspace->users()->attach($recipient->id, ['role' => 'worker', 'permissions' => []]);
$recipient->notify(new NudgeNotification($declaration, $user));
Notification::assertSentTo($recipient, NudgeNotification::class, function ($notification, $channels) {
return in_array('database', $channels) && in_array('mail', $channels);
});
});
test('nudge notification payload includes workspace_id and notification_type', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$notification = new NudgeNotification($declaration, $user);
$payload = $notification->toDatabase($user);
expect($payload)->toHaveKey('workspace_id', $workspace->id)
->toHaveKey('declaration_id', $declaration->id)
->toHaveKey('sender_id', $user->id)
->toHaveKey('notification_type', NotificationType::Nudge);
});
test('nudge notification toMail returns NudgeNotificationMail', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$notification = new NudgeNotification($declaration, $user);
$mailable = $notification->toMail($user);
expect($mailable)->toBeInstanceOf(NudgeNotificationMail::class);
});
// ── DocumentUploadedNotification ────────────────────────────
test('document uploaded notification sends via database channel only', function () {
Notification::fake();
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
Notification::assertSentTo($user, DocumentUploadedNotification::class, function ($notification, $channels) {
return $channels === ['database'];
});
});
test('document uploaded notification payload includes workspace_id and notification_type', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$notification = new DocumentUploadedNotification($declaration, $declaration->client_id);
$payload = $notification->toDatabase($user);
expect($payload)->toHaveKey('workspace_id', $workspace->id)
->toHaveKey('declaration_id', $declaration->id)
->toHaveKey('client_id', $declaration->client_id)
->toHaveKey('notification_type', NotificationType::DocumentUploaded);
});
// ── DeclarationOverdueNotification ──────────────────────────
test('declaration overdue notification sends via database and mail channels', function () {
Notification::fake();
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$user->notify(new DeclarationOverdueNotification($declaration));
Notification::assertSentTo($user, DeclarationOverdueNotification::class, function ($notification, $channels) {
return in_array('database', $channels) && in_array('mail', $channels);
});
});
test('declaration overdue notification payload includes workspace_id and notification_type', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$notification = new DeclarationOverdueNotification($declaration);
$payload = $notification->toDatabase($user);
expect($payload)->toHaveKey('workspace_id', $workspace->id)
->toHaveKey('declaration_id', $declaration->id)
->toHaveKey('notification_type', NotificationType::DeclarationOverdue);
});
test('declaration overdue notification has retry configuration', function () {
$declaration = Declaration::factory()->make();
$notification = new DeclarationOverdueNotification($declaration);
expect($notification->tries)->toBe(3);
});
test('nudge notification has retry configuration', function () {
$declaration = Declaration::factory()->make();
$sender = User::factory()->make();
$notification = new NudgeNotification($declaration, $sender);
expect($notification->tries)->toBe(3);
});
// ── NotificationController ──────────────────────────────────
test('notification controller index returns paginated workspace-scoped notifications', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
// Create a notification in the database for this user
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
$response = $this->actingAs($user)->get(route('notifications.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('notifications/Index')
->has('notifications')
->has('markAllReadUrl')
);
});
test('notification controller markAsRead marks notification as read', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
$notification = $user->notifications()->first();
expect($notification->read_at)->toBeNull();
$response = $this->actingAs($user)->post(route('notifications.read', $notification->id));
$response->assertRedirect();
expect($notification->fresh()->read_at)->not->toBeNull();
});
test('notification controller markAllAsRead marks all as read', function () {
[$user, $workspace] = setupNotificationUser();
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
expect($user->unreadNotifications)->toHaveCount(2);
$response = $this->actingAs($user)->post(route('notifications.readAll'));
$response->assertRedirect();
$user->refresh();
expect($user->unreadNotifications)->toHaveCount(0);
});
test('unauthenticated users cannot access notification routes', function () {
$this->get(route('notifications.index'))->assertRedirect(route('login'));
$this->post(route('notifications.readAll'))->assertRedirect(route('login'));
});