feat: add bulk client notifications and email enhancements with review fixes (Stories 3.4 & 3.5)
Story 3-4: Bulk client notification scheduling — BulkNotificationController, BulkActionBar component, checkbox selection on declarations index. Story 3-5: Email notification enhancement — observer-driven email on en_attente_client, cache invalidation on ferme, workspace branding on all email templates, 11 feature tests. Code review fixes: - Move bulk-notify route above resource wildcard to prevent shadowing - Add static $suppressEmail flag to prevent observer double-sending when DeclarationMessageController already sends the email - Fix canBulkNotify logic (was granting workers access) - Add WorkspaceUserRole check to BulkNotifyRequest::authorize() - Replace firstOrCreate with explicit invitation lookup that syncs client email and handles used/expired invitations correctly - Watch declarations.data instead of current_page to clear selection on filter/sort changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
tests/Feature/Notifications/BulkNotificationTest.php
Normal file
211
tests/Feature/Notifications/BulkNotificationTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\DeclarationStatus;
|
||||
use App\Mail\DeclarationFileRequestMail;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
function setupBulkNotifyTest(string $role = 'owner', int $declarationCount = 3): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$workspace->users()->attach($user->id, ['role' => $role, 'permissions' => []]);
|
||||
|
||||
$declarations = Declaration::factory()
|
||||
->forWorkspace($workspace)
|
||||
->count($declarationCount)
|
||||
->create(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace, $declarations];
|
||||
}
|
||||
|
||||
// ── AC1,3,4: Owner/Manager can bulk-notify clients ──────
|
||||
|
||||
test('owner can bulk-notify clients — emails queued, activity logged, success flash', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner');
|
||||
|
||||
$ids = $declarations->pluck('id')->all();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => $ids,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'success',
|
||||
'message' => '3 notifications envoyées',
|
||||
]);
|
||||
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, 3);
|
||||
});
|
||||
|
||||
test('manager can bulk-notify clients — same behavior as owner', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declarations] = setupBulkNotifyTest('manager');
|
||||
|
||||
$ids = $declarations->pluck('id')->all();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => $ids,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'success',
|
||||
'message' => '3 notifications envoyées',
|
||||
]);
|
||||
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, 3);
|
||||
});
|
||||
|
||||
// ── AC5: Worker cannot access bulk-notify (consistent with canEdit, canDelete, canNudge) ──
|
||||
|
||||
test('worker is forbidden from bulk-notify endpoint', function () {
|
||||
Mail::fake();
|
||||
|
||||
$worker = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$workspace->users()->attach($worker->id, ['role' => 'worker', 'permissions' => []]);
|
||||
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||
'status' => DeclarationStatus::EnAttenteClient,
|
||||
'assigned_to' => $worker->id,
|
||||
]);
|
||||
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
$response = $this->actingAs($worker)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => [$declaration->id],
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
// ── AC3: Rejects declarations not in en_attente_client status ──
|
||||
|
||||
test('rejects declarations not in en_attente_client status', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 2);
|
||||
|
||||
// Change one declaration to a different status
|
||||
$declarations[0]->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
$ids = $declarations->pluck('id')->all();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => $ids,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'success',
|
||||
'message' => '1 notifications envoyées',
|
||||
]);
|
||||
|
||||
// Only the one remaining en_attente_client declaration should get an email
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, 1);
|
||||
});
|
||||
|
||||
// ── AC4: Workspace boundary enforcement ──
|
||||
|
||||
test('workspace boundary enforcement — declarations from other workspace ignored', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 1);
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$otherDeclaration = Declaration::factory()->forWorkspace($otherWorkspace)->create([
|
||||
'status' => DeclarationStatus::EnAttenteClient,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => [$declarations[0]->id, $otherDeclaration->id],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
// Only 1 email (from own workspace), the other is filtered out
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, 1);
|
||||
});
|
||||
|
||||
// ── AC4: Activity log records bulk operation ──
|
||||
|
||||
test('activity log records bulk operation with count and actor', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 2);
|
||||
|
||||
$ids = $declarations->pluck('id')->all();
|
||||
|
||||
$this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => $ids,
|
||||
]);
|
||||
|
||||
$activity = Activity::query()
|
||||
->where('subject_type', Workspace::class)
|
||||
->where('subject_id', $workspace->id)
|
||||
->where('causer_id', $user->id)
|
||||
->where('description', 'bulk_client_notification')
|
||||
->first();
|
||||
|
||||
expect($activity)->not->toBeNull();
|
||||
expect($activity->properties['count'])->toBe(2);
|
||||
expect($activity->properties['declaration_ids'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
// ── Throttle middleware prevents abuse ──
|
||||
|
||||
test('throttle middleware prevents abuse — 429 after 5 requests', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 1);
|
||||
$ids = $declarations->pluck('id')->all();
|
||||
|
||||
// Send 5 requests (within the throttle limit)
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => $ids,
|
||||
]);
|
||||
}
|
||||
|
||||
// 6th request should be throttled
|
||||
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => $ids,
|
||||
]);
|
||||
|
||||
$response->assertStatus(429);
|
||||
});
|
||||
|
||||
// ── Unauthenticated user cannot bulk-notify ──
|
||||
|
||||
test('unauthenticated user cannot bulk-notify — redirected to login', function () {
|
||||
$response = $this->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => [1],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
// ── Validation: empty declaration_ids rejected ──
|
||||
|
||||
test('validation rejects empty declaration_ids', function () {
|
||||
[$user, $workspace] = setupBulkNotifyTest('owner', 0);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||
'declaration_ids' => [],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('declaration_ids');
|
||||
});
|
||||
195
tests/Feature/Notifications/EmailNotificationTest.php
Normal file
195
tests/Feature/Notifications/EmailNotificationTest.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\DeclarationStatus;
|
||||
use App\Mail\DeclarationFileRequestMail;
|
||||
use App\Mail\NudgeNotificationMail;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\DocumentUploadedNotification;
|
||||
use App\Notifications\NudgeNotification;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
function setupEmailNotificationTest(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
'assigned_to' => $user->id,
|
||||
]);
|
||||
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace, $declaration];
|
||||
}
|
||||
|
||||
// ── AC3: Status change to en_attente_client triggers client email ──
|
||||
|
||||
test('status change to en_attente_client queues client email', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, function ($mail) use ($declaration) {
|
||||
return $mail->declaration->id === $declaration->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('status change to en_attente_client creates declaration invitation', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
expect($declaration->invitations()->count())->toBe(1);
|
||||
expect($declaration->invitations()->first()->email)->toBe($declaration->client->contact_email);
|
||||
});
|
||||
|
||||
test('status change to en_attente_client skips email when client has no email', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||
|
||||
$declaration->client->update(['contact_email' => null]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
// ── AC4: Status change to ferme invalidates dashboard cache ──
|
||||
|
||||
test('status change to ferme invalidates dashboard cache', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||
'status' => DeclarationStatus::Termine,
|
||||
'assigned_to' => $user->id,
|
||||
]);
|
||||
|
||||
$cacheKey = "dashboard:{$workspace->id}:{$user->id}";
|
||||
Cache::put($cacheKey, 'cached-data', 300);
|
||||
|
||||
expect(Cache::has($cacheKey))->toBeTrue();
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||
|
||||
expect(Cache::has($cacheKey))->toBeFalse();
|
||||
});
|
||||
|
||||
// ── AC6: Status change to en_cours does NOT trigger any email ──
|
||||
|
||||
test('status change to en_cours does not trigger any email', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||
'status' => DeclarationStatus::Created,
|
||||
'assigned_to' => $user->id,
|
||||
]);
|
||||
|
||||
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
// ── AC2: DocumentUploadedNotification has no mail channel ──
|
||||
|
||||
test('document uploaded notification has no mail channel', function () {
|
||||
$declaration = Declaration::factory()->create();
|
||||
$notification = new DocumentUploadedNotification($declaration, 1);
|
||||
|
||||
$channels = $notification->via(new stdClass);
|
||||
|
||||
expect($channels)->toBe(['database']);
|
||||
expect($channels)->not->toContain('mail');
|
||||
});
|
||||
|
||||
// ── AC1: Nudge email contains required fields ──
|
||||
|
||||
test('nudge email contains required fields', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['name' => 'Cabinet Test']);
|
||||
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||
'assigned_to' => $user->id,
|
||||
]);
|
||||
|
||||
$mail = new NudgeNotificationMail($declaration, $user);
|
||||
|
||||
$rendered = $mail->render();
|
||||
|
||||
expect($rendered)->toContain($user->name);
|
||||
expect($rendered)->toContain('Voir la déclaration');
|
||||
expect($rendered)->toContain('Cabinet Test');
|
||||
});
|
||||
|
||||
// ── AC1: Nudge notification sends via database and mail channels ──
|
||||
|
||||
test('nudge notification sends via database and mail channels', function () {
|
||||
$declaration = Declaration::factory()->create();
|
||||
$sender = User::factory()->create();
|
||||
$notification = new NudgeNotification($declaration, $sender);
|
||||
|
||||
$channels = $notification->via(new stdClass);
|
||||
|
||||
expect($channels)->toBe(['database', 'mail']);
|
||||
});
|
||||
|
||||
// ── AC6: Workspace scoping on notification dispatches ──
|
||||
|
||||
test('status change email is scoped to the correct workspace client', function () {
|
||||
Mail::fake();
|
||||
|
||||
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$otherDeclaration = Declaration::factory()->forWorkspace($otherWorkspace)->create([
|
||||
'status' => DeclarationStatus::EnCours,
|
||||
]);
|
||||
|
||||
// Only our declaration should trigger email
|
||||
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
$otherDeclaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, 2);
|
||||
|
||||
// Each email goes to the correct client
|
||||
Mail::assertQueued(DeclarationFileRequestMail::class, function ($mail) use ($declaration) {
|
||||
return $mail->declaration->id === $declaration->id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── AC5: Failed email retries — verify $tries and $backoff properties ──
|
||||
|
||||
test('nudge notification has retry configuration', function () {
|
||||
$declaration = Declaration::factory()->create();
|
||||
$sender = User::factory()->create();
|
||||
$notification = new NudgeNotification($declaration, $sender);
|
||||
|
||||
expect($notification->tries)->toBe(3);
|
||||
expect($notification->backoff)->toBe(60);
|
||||
});
|
||||
|
||||
test('declaration overdue notification has retry configuration', function () {
|
||||
$declaration = Declaration::factory()->create();
|
||||
$notification = new \App\Notifications\DeclarationOverdueNotification($declaration);
|
||||
|
||||
expect($notification->tries)->toBe(3);
|
||||
expect($notification->backoff)->toBe(60);
|
||||
});
|
||||
Reference in New Issue
Block a user