From 1ab3cfc445f6fced75903a4cc7103923029c3351 Mon Sep 17 00:00:00 2001 From: Saad Zoubir Date: Thu, 26 Mar 2026 11:26:03 +0100 Subject: [PATCH] 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) --- .../3-1-notification-infrastructure-setup.md | 262 ++++++++++++++++++ app/Enums/NotificationType.php | 34 +++ app/Mail/NudgeNotificationMail.php | 43 +++ .../DeclarationOverdueNotification.php | 56 ++++ .../DocumentUploadedNotification.php | 42 +++ app/Notifications/NudgeNotification.php | 53 ++++ .../emails/declaration-overdue.blade.php | 13 + .../views/emails/nudge-notification.blade.php | 15 + .../NotificationControllerTest.php | 19 +- .../NotificationInfrastructureTest.php | 203 ++++++++++++++ 10 files changed, 731 insertions(+), 9 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/3-1-notification-infrastructure-setup.md create mode 100644 app/Enums/NotificationType.php create mode 100644 app/Mail/NudgeNotificationMail.php create mode 100644 app/Notifications/DeclarationOverdueNotification.php create mode 100644 app/Notifications/DocumentUploadedNotification.php create mode 100644 app/Notifications/NudgeNotification.php create mode 100644 resources/views/emails/declaration-overdue.blade.php create mode 100644 resources/views/emails/nudge-notification.blade.php create mode 100644 tests/Feature/Notifications/NotificationInfrastructureTest.php diff --git a/_bmad-output/implementation-artifacts/3-1-notification-infrastructure-setup.md b/_bmad-output/implementation-artifacts/3-1-notification-infrastructure-setup.md new file mode 100644 index 0000000..2de9027 --- /dev/null +++ b/_bmad-output/implementation-artifacts/3-1-notification-infrastructure-setup.md @@ -0,0 +1,262 @@ +# Story 3.1: Notification Infrastructure Setup + +Status: review + +## Story + +As a developer, +I want the Laravel notification system configured with database and mail channels, a NotificationType enum, and foundational notification classes, +so that all future notification features (nudges, alerts, bulk notifications) have a reliable, consistent foundation to build on. + +## Acceptance Criteria + +1. **AC1: Notifications table migration exists and runs** + - Given Redis queue is configured (from Story 0.4) + - When `php artisan migrate` is run + - Then the `notifications` table exists with Laravel's standard schema (uuid id, type, morphs notifiable, text data, read_at, timestamps) + +2. **AC2: User model confirms Notifiable trait** + - Then `User` model uses `Illuminate\Notifications\Notifiable` trait + +3. **AC3: NotificationType enum created** + - Then `app/Enums/NotificationType.php` exists using `bensampo/laravel-enum` + - And values include: `nudge`, `declaration_overdue`, `document_uploaded`, `bulk_notification`, `status_changed` + +4. **AC4: Base notification pattern established** + - Then all new notification classes implement `ShouldQueue`, use `Queueable` trait + - And all notification `toArray()`/`toDatabase()` payloads include `workspace_id` + - And all notification `toArray()`/`toDatabase()` payloads include `notification_type` from the `NotificationType` enum + +5. **AC5: NudgeNotification class created** + - Then `app/Notifications/NudgeNotification.php` exists with `database` and `mail` channels + - And payload includes: `workspace_id`, `declaration_id`, `sender_id`, `notification_type` = `nudge` + - And `toMail()` returns a queued `NudgeNotificationMail` Markdown mailable + +6. **AC6: DocumentUploadedNotification class created** + - Then `app/Notifications/DocumentUploadedNotification.php` exists with `database` channel only (no email — reduces noise) + - And payload includes: `workspace_id`, `declaration_id`, `client_id`, `notification_type` = `document_uploaded` + +7. **AC7: DeclarationOverdueNotification class created** + - Then `app/Notifications/DeclarationOverdueNotification.php` exists with `database` and `mail` channels + - And payload includes: `workspace_id`, `declaration_id`, `notification_type` = `declaration_overdue` + - And queued email sending with up to 3 retries (`$tries = 3` per NFR26) + +8. **AC8: NudgeNotificationMail mailable created** + - Then `app/Mail/NudgeNotificationMail.php` exists as a Markdown mailable + - And it renders with sender name, declaration details (client name, type, due_date), and a direct link button ("Voir la declaration") + - And uses professional French copy + +9. **AC9: Notification queries are workspace-scoped** + - Then when fetching notifications, they are filtered by `workspace_id` in the JSON data payload + - And `NotificationController` is extended with an `index` method returning workspace-scoped notifications + +10. **AC10: Routes registered for notification endpoints** + - Then `GET /notifications` route exists → `NotificationController@index` (Inertia page) + - And `POST /notifications/mark-all-read` route exists → `NotificationController@markAllAsRead` + - And existing `POST /notifications/{id}/read` route remains functional + +11. **AC11: All notification classes have retry configuration** + - Then notification classes with mail channel set `public $tries = 3` (NFR26) + +## Tasks / Subtasks + +- [x] Task 1: Verify existing notifications infrastructure (AC: #1, #2) + - [x] 1.1 Confirm `notifications` table migration already exists (`2026_03_09_000004_create_notifications_table.php`) — do NOT create a duplicate + - [x] 1.2 Confirm `User` model already has `Notifiable` trait — already present, no changes needed + - [x] 1.3 Run `php artisan migrate` to confirm table exists + +- [x] Task 2: Create NotificationType enum (AC: #3) + - [x] 2.1 Create `app/Enums/NotificationType.php` using `bensampo/laravel-enum` pattern + - [x] 2.2 Values: `nudge`, `declaration_overdue`, `document_uploaded`, `bulk_notification`, `status_changed` + - [x] 2.3 Follow existing enum patterns (see `DeclarationStatus.php` for reference) + +- [x] Task 3: Create NudgeNotification class (AC: #4, #5, #11) + - [x] 3.1 Create `app/Notifications/NudgeNotification.php` + - [x] 3.2 Implement `ShouldQueue`, use `Queueable` trait, set `$tries = 3` + - [x] 3.3 Channels: `['database', 'mail']` + - [x] 3.4 `toDatabase()` payload: `workspace_id`, `declaration_id`, `sender_id`, `notification_type` → `nudge` + - [x] 3.5 `toMail()` returns `NudgeNotificationMail` mailable + +- [x] Task 4: Create DocumentUploadedNotification class (AC: #4, #6) + - [x] 4.1 Create `app/Notifications/DocumentUploadedNotification.php` + - [x] 4.2 Implement `ShouldQueue`, use `Queueable` trait + - [x] 4.3 Channels: `['database']` only — no mail + - [x] 4.4 `toDatabase()` payload: `workspace_id`, `declaration_id`, `client_id`, `notification_type` → `document_uploaded` + +- [x] Task 5: Create DeclarationOverdueNotification class (AC: #4, #7, #11) + - [x] 5.1 Create `app/Notifications/DeclarationOverdueNotification.php` + - [x] 5.2 Implement `ShouldQueue`, use `Queueable` trait, set `$tries = 3` + - [x] 5.3 Channels: `['database', 'mail']` + - [x] 5.4 `toDatabase()` payload: `workspace_id`, `declaration_id`, `notification_type` → `declaration_overdue` + - [x] 5.5 `toMail()` returns MailMessage with declaration details and link + +- [x] Task 6: Create NudgeNotificationMail mailable (AC: #8) + - [x] 6.1 Create `app/Mail/NudgeNotificationMail.php` as Markdown mailable + - [x] 6.2 Create Markdown template `resources/views/emails/nudge-notification.blade.php` + - [x] 6.3 Include: sender name, declaration client name, declaration type, due_date, direct link button ("Voir la declaration") + - [x] 6.4 Professional French copy, include workspace firm name in header + +- [x] Task 7: Extend NotificationController with index (AC: #9, #10) + - [x] 7.1 Add `index()` method to existing `NotificationController` + - [x] 7.2 Fetch user notifications, filter by `workspace_id` in JSON data matching current workspace + - [x] 7.3 Paginate 25 per page, reverse chronological order + - [x] 7.4 Return Inertia render to `notifications/Index` page + - [x] 7.5 Register `GET /notifications` route in `web.php` + +- [x] Task 8: Clean up legacy notification (no AC — housekeeping) + - [x] 8.1 Evaluate `FolderMentionNotification.php` — this references the deprecated `Folder` model and `folders.show` route. Flag for removal or skip if out of scope + +- [x] Task 9: Create notification type definitions for frontend (AC: #9) + - [x] 9.1 Create `resources/js/types/notification.ts` with TypeScript types for notification data + +- [x] Task 10: Write tests (all ACs) + - [x] 10.1 Create `tests/Feature/Notifications/NotificationInfrastructureTest.php` + - [x] 10.2 Test: NotificationType enum has all expected values + - [x] 10.3 Test: NudgeNotification sends via database and mail channels (`Notification::fake()`) + - [x] 10.4 Test: DocumentUploadedNotification sends via database channel only + - [x] 10.5 Test: DeclarationOverdueNotification sends via database and mail channels + - [x] 10.6 Test: All notification payloads include `workspace_id` and `notification_type` + - [x] 10.7 Test: NotificationController@index returns paginated, workspace-scoped notifications + - [x] 10.8 Test: NotificationController@markAsRead marks notification as read + - [x] 10.9 Test: NotificationController@markAllAsRead marks all as read + - [x] 10.10 Test: Unauthenticated users cannot access notification routes + +## Retrospective Intelligence + +**From Epic 2 Retrospective (2026-03-24):** + +- **CRITICAL — Pre-existing test failures:** 15 tests were failing across 3 retrospectives. Commit `716e9fc` resolved them. Verify all 222+ tests still pass before starting and after finishing this story. +- **Team Agreement — Load retro as context:** This story was created with full retrospective intelligence (this section proves it). +- **Team Agreement — Pre-existing test failures must not carry:** Run full test suite before declaring this story done. Zero failures tolerance. +- **Deferred Item D-1:** Nudge/Reassign dropdown items in dashboard are unconditionally disabled — Story 3.2 will enable them. This story creates the `NudgeNotification` they depend on. +- **Architecture doc drift was corrected** in commit `6956f7b` — `due_date` (not `deadline`), no `Declaration::workspace()` scope, `mise_en_demeure` status documented. +- **`withoutVite()` global workaround** in `tests/Pest.php` — confirmed compatible with notification tests (no Vite manifest needed for backend tests). +- **Context management is the team's superpower** — rich story specs with previous intelligence prevent errors across stories. + +**From Epic 1 Retrospective:** + +- **withPivot gotchas now documented** in project-context.md (commit `6956f7b`). When loading workspace members, always chain `->withPivot('role', 'permissions')`. + +## Dev Notes + +### Existing Infrastructure (DO NOT recreate) + +- **`notifications` table migration ALREADY EXISTS:** `database/migrations/2026_03_09_000004_create_notifications_table.php` — standard Laravel schema with uuid id, type, morphs notifiable, text data, read_at, timestamps. Do NOT run `php artisan notifications:table` or create a duplicate. +- **`User` model ALREADY has `Notifiable` trait:** Line 19 of `app/Models/User.php` — `use HasFactory, LogsActivity, Notifiable, SoftDeletes, TwoFactorAuthenticatable;` +- **`NotificationController` ALREADY EXISTS:** `app/Http/Controllers/NotificationController.php` with `markAsRead()` and `markAllAsRead()` methods. Extend this controller, do not replace it. +- **Existing routes:** `POST /notifications/{id}/read` and `POST /notifications/read-all` already registered in `web.php`. Add the new `GET /notifications` route alongside them. +- **Existing notification classes:** `DeclarationMentionNotification.php` (database + mail, ShouldQueue, Queueable — good pattern to follow) and `FolderMentionNotification.php` (legacy — references deprecated `Folder` model). + +### Existing Notification Pattern to Follow + +The `DeclarationMentionNotification` is the reference implementation: +- Constructor with promoted properties: `public Declaration $declaration, public User $mentionedBy, public string $message` +- `via()` returns `['database', 'mail']` +- `toDatabase()` returns array with declaration details + URL via `route()` helper +- `toMail()` returns `MailMessage` with `->markdown()` template +- French subject line pattern: `'Vous avez été mentionné - '.$this->declaration->title` + +### Architecture Constraints + +- **D8:** Use Laravel's built-in `DatabaseNotification` system — no custom notification tables +- **D5:** Redis queue driver — all notification classes implement `ShouldQueue` + `Queueable` +- **D10:** No real-time features for MVP — notifications load on page visit only +- **Workspace scoping:** Filter notifications by `workspace_id` in the JSON `data` column, not by a direct relationship. Use `whereJsonContains('data->workspace_id', $workspace->id)` or filter after fetching. +- **Notification type enum values:** snake_case (`nudge`, `declaration_overdue`, etc.) +- **Route pattern from architecture:** `Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index');` + +### Critical Gotchas + +- **Declaration column is `due_date`** NOT `deadline` — use `$declaration->due_date` everywhere +- **Declaration scoping:** No `Declaration::workspace()` scope — use `Declaration::where('workspace_id', $workspace->id)` +- **Declaration statuses:** 6 values — `created`, `en_cours`, `en_attente_client`, `mise_en_demeure`, `termine`, `ferme` +- **Authorization returns 404** not 403 for workspace boundary violations +- **Workspace is session-based** — resolve from `$request->user()->currentWorkspace` or session `current_workspace_id` +- **Enum pattern:** Use `bensampo/laravel-enum` — look at `DeclarationStatus.php` for exact syntax +- **All URLs passed as props** from PHP controllers — never hardcode in Vue +- **Use `route()` helper** for URLs in notification payloads and tests + +### Testing Standards + +- Pest syntax with `test()` closures +- `Notification::fake()` for testing notification dispatch +- `Mail::fake()` if testing mail separately +- Feature tests in `tests/Feature/Notifications/` +- Use `route()` helper for URLs, never hardcoded paths +- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually +- Run `composer test` to verify all tests pass (currently 222+ tests) + +### Project Structure Notes + +**New files to create:** +- `app/Enums/NotificationType.php` — follows existing enum pattern in `app/Enums/` +- `app/Notifications/NudgeNotification.php` — follows existing `DeclarationMentionNotification.php` pattern +- `app/Notifications/DocumentUploadedNotification.php` — database channel only +- `app/Notifications/DeclarationOverdueNotification.php` — database + mail channels +- `app/Mail/NudgeNotificationMail.php` — Markdown mailable, follows existing mail pattern in `app/Mail/` +- `resources/views/emails/nudge-notification.blade.php` — Markdown mail template +- `resources/js/types/notification.ts` — TypeScript type definitions +- `tests/Feature/Notifications/NotificationInfrastructureTest.php` + +**Existing files to modify:** +- `app/Http/Controllers/NotificationController.php` — add `index()` method +- `routes/web.php` — add `GET /notifications` route + +**Files NOT to touch:** +- `database/migrations/2026_03_09_000004_create_notifications_table.php` — already exists +- `app/Models/User.php` — already has `Notifiable` trait +- `resources/js/components/ui/*` — shadcn-vue, never modify + +### References + +- [Source: _bmad-output/planning-artifacts/architecture.md#D8 — In-App Notification System] +- [Source: _bmad-output/planning-artifacts/architecture.md#D5 — Queue Driver] +- [Source: _bmad-output/planning-artifacts/architecture.md#Notification Patterns] +- [Source: _bmad-output/planning-artifacts/architecture.md#Phase 3 File Structure] +- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1] +- [Source: _bmad-output/planning-artifacts/prd.md#FR29-FR33] +- [Source: _bmad-output/implementation-artifacts/epic-2-retro-2026-03-24.md#Action Items] +- [Source: _bmad-output/project-context.md#Critical Implementation Rules] + +## Dev Agent Record + +### Agent Model Used +Claude Opus 4.6 (1M context) + +### Debug Log References +- Baseline test suite: 223 tests passed before implementation +- Final test suite: 237 tests passed (14 new notification tests added), zero failures +- One test fix: created `notifications/Index.vue` page component to satisfy Inertia page existence check in test + +### Completion Notes List +- Verified existing infrastructure: notifications migration, User Notifiable trait, NotificationController with markAsRead/markAllAsRead +- Created NotificationType enum with 5 values following bensampo/laravel-enum pattern +- Created NudgeNotification (database+mail, $tries=3, toMail returns NudgeNotificationMail) +- Created DocumentUploadedNotification (database only, no mail to reduce noise) +- Created DeclarationOverdueNotification (database+mail, $tries=3, French subject line) +- Created NudgeNotificationMail as Markdown mailable with professional French copy +- Created declaration-overdue email template +- Extended NotificationController with workspace-scoped index() using whereJsonContains +- Added GET /notifications and POST /notifications/mark-all-read routes inside workspace middleware +- Created notifications/Index.vue page with basic notification list UI +- Flagged FolderMentionNotification as legacy (references deprecated Folder model) — skipped removal as out of scope +- Created TypeScript notification types in resources/js/types/notification.ts +- 14 comprehensive tests covering all ACs + +### File List +- `app/Enums/NotificationType.php` (new) +- `app/Notifications/NudgeNotification.php` (new) +- `app/Notifications/DocumentUploadedNotification.php` (new) +- `app/Notifications/DeclarationOverdueNotification.php` (new) +- `app/Mail/NudgeNotificationMail.php` (new) +- `resources/views/emails/nudge-notification.blade.php` (new) +- `resources/views/emails/declaration-overdue.blade.php` (new) +- `resources/js/pages/notifications/Index.vue` (new) +- `resources/js/types/notification.ts` (new) +- `resources/js/types/index.ts` (modified — added notification export) +- `app/Http/Controllers/NotificationController.php` (modified — added index method) +- `routes/web.php` (modified — added GET /notifications and POST /notifications/mark-all-read routes) +- `tests/Feature/Notifications/NotificationInfrastructureTest.php` (new) + +## Change Log +- 2026-03-24: Story 3.1 implementation complete — notification infrastructure with enum, 3 notification classes, mailable, controller index, routes, frontend types, and 14 tests diff --git a/app/Enums/NotificationType.php b/app/Enums/NotificationType.php new file mode 100644 index 0000000..272c12a --- /dev/null +++ b/app/Enums/NotificationType.php @@ -0,0 +1,34 @@ + + */ + public static function labels(): array + { + return [ + self::Nudge => 'Relance', + self::DeclarationOverdue => 'Déclaration en retard', + self::DocumentUploaded => 'Document téléversé', + self::BulkNotification => 'Notification groupée', + self::StatusChanged => 'Statut modifié', + ]; + } +} diff --git a/app/Mail/NudgeNotificationMail.php b/app/Mail/NudgeNotificationMail.php new file mode 100644 index 0000000..23bb008 --- /dev/null +++ b/app/Mail/NudgeNotificationMail.php @@ -0,0 +1,43 @@ +declaration->title ?? 'Sans titre'), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.nudge-notification', + with: [ + 'senderName' => $this->sender->name, + 'clientName' => $this->declaration->client?->name, + 'declarationType' => $this->declaration->type?->value, + 'dueDate' => $this->declaration->due_date?->format('d/m/Y'), + 'url' => route('declarations.show', $this->declaration), + 'firmName' => $this->declaration->workspace?->name, + ] + ); + } +} diff --git a/app/Notifications/DeclarationOverdueNotification.php b/app/Notifications/DeclarationOverdueNotification.php new file mode 100644 index 0000000..93281c4 --- /dev/null +++ b/app/Notifications/DeclarationOverdueNotification.php @@ -0,0 +1,56 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + return [ + 'workspace_id' => $this->declaration->workspace_id, + 'declaration_id' => $this->declaration->id, + 'notification_type' => NotificationType::DeclarationOverdue, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Déclaration en retard - '.($this->declaration->title ?? 'Sans titre')) + ->markdown('emails.declaration-overdue', [ + 'declarationTitle' => $this->declaration->title ?? 'Sans titre', + 'dueDate' => $this->declaration->due_date?->format('d/m/Y'), + 'url' => route('declarations.show', $this->declaration), + ]); + } +} diff --git a/app/Notifications/DocumentUploadedNotification.php b/app/Notifications/DocumentUploadedNotification.php new file mode 100644 index 0000000..d66be1a --- /dev/null +++ b/app/Notifications/DocumentUploadedNotification.php @@ -0,0 +1,42 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + return [ + 'workspace_id' => $this->declaration->workspace_id, + 'declaration_id' => $this->declaration->id, + 'client_id' => $this->clientId, + 'notification_type' => NotificationType::DocumentUploaded, + ]; + } +} diff --git a/app/Notifications/NudgeNotification.php b/app/Notifications/NudgeNotification.php new file mode 100644 index 0000000..d95efa4 --- /dev/null +++ b/app/Notifications/NudgeNotification.php @@ -0,0 +1,53 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + return [ + 'workspace_id' => $this->declaration->workspace_id, + 'declaration_id' => $this->declaration->id, + 'sender_id' => $this->sender->id, + 'notification_type' => NotificationType::Nudge, + ]; + } + + public function toMail(object $notifiable): NudgeNotificationMail + { + return new NudgeNotificationMail($this->declaration, $this->sender); + } +} diff --git a/resources/views/emails/declaration-overdue.blade.php b/resources/views/emails/declaration-overdue.blade.php new file mode 100644 index 0000000..4b2a2d5 --- /dev/null +++ b/resources/views/emails/declaration-overdue.blade.php @@ -0,0 +1,13 @@ + +# Déclaration en retard + +Bonjour, + +La déclaration **{{ $declarationTitle }}** a dépassé son échéance prévue le **{{ $dueDate }}**. + +Veuillez traiter cette déclaration dans les meilleurs délais. + + +Voir la déclaration + + diff --git a/resources/views/emails/nudge-notification.blade.php b/resources/views/emails/nudge-notification.blade.php new file mode 100644 index 0000000..585df92 --- /dev/null +++ b/resources/views/emails/nudge-notification.blade.php @@ -0,0 +1,15 @@ + +# Relance + +Bonjour, + +**{{ $senderName }}** de **{{ $firmName }}** vous envoie une relance concernant la déclaration suivante : + +- **Client :** {{ $clientName }} +- **Type :** {{ $declarationType }} +- **Échéance :** {{ $dueDate }} + + +Voir la déclaration + + diff --git a/tests/Feature/Notification/NotificationControllerTest.php b/tests/Feature/Notification/NotificationControllerTest.php index 2e8dae9..9679e3f 100644 --- a/tests/Feature/Notification/NotificationControllerTest.php +++ b/tests/Feature/Notification/NotificationControllerTest.php @@ -5,11 +5,13 @@ use App\Models\Declaration; use App\Models\User; use App\Models\Workspace; use App\Notifications\DeclarationMentionNotification; +use App\Notifications\DocumentUploadedNotification; test('user can mark own notification as read', function () { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user, ['role' => 'owner']); + $workspace->users()->attach($user, ['role' => 'owner', 'permissions' => json_encode([])]); + $user->update(['current_workspace_id' => $workspace->id]); $client = Client::factory()->create(['workspace_id' => $workspace->id]); $declaration = Declaration::factory()->create([ 'workspace_id' => $workspace->id, @@ -37,7 +39,9 @@ test('cannot mark another user notification as read', function () { $user = User::factory()->create(); $other = User::factory()->create(); $workspace = Workspace::factory()->create(); - $workspace->users()->attach($other, ['role' => 'owner']); + $workspace->users()->attach($user, ['role' => 'owner', 'permissions' => json_encode([])]); + $workspace->users()->attach($other, ['role' => 'worker', 'permissions' => json_encode([])]); + $user->update(['current_workspace_id' => $workspace->id]); $client = Client::factory()->create(['workspace_id' => $workspace->id]); $declaration = Declaration::factory()->create([ 'workspace_id' => $workspace->id, @@ -60,20 +64,16 @@ test('cannot mark another user notification as read', function () { test('user can mark all notifications as read', function () { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user, ['role' => 'owner']); + $workspace->users()->attach($user, ['role' => 'owner', 'permissions' => json_encode([])]); + $user->update(['current_workspace_id' => $workspace->id]); $client = Client::factory()->create(['workspace_id' => $workspace->id]); - $mentionedBy = User::factory()->create(); for ($i = 0; $i < 3; $i++) { $declaration = Declaration::factory()->create([ 'workspace_id' => $workspace->id, 'client_id' => $client->id, ]); - $user->notify(new DeclarationMentionNotification( - $declaration, - $mentionedBy, - "Message $i", - )); + $user->notify(new DocumentUploadedNotification($declaration, $client->id)); } expect($user->unreadNotifications()->count())->toBe(3); @@ -81,5 +81,6 @@ test('user can mark all notifications as read', function () { $response = $this->actingAs($user)->post(route('notifications.readAll')); $response->assertRedirect(); + $user->refresh(); expect($user->unreadNotifications()->count())->toBe(0); }); diff --git a/tests/Feature/Notifications/NotificationInfrastructureTest.php b/tests/Feature/Notifications/NotificationInfrastructureTest.php new file mode 100644 index 0000000..c00b7ea --- /dev/null +++ b/tests/Feature/Notifications/NotificationInfrastructureTest.php @@ -0,0 +1,203 @@ +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')); +});