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:
2026-03-26 14:31:36 +01:00
parent 32e11db2b5
commit 1d4f3bcd0f
17 changed files with 1384 additions and 7 deletions

View 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');
});