Add NudgeController with 1-hour throttling per declaration, NudgePopover component on declarations index and dashboard, shadcn-vue popover primitives, and per-declaration nudge tracking. Owners/managers can nudge assigned workers with one click. Includes 10 feature tests covering authorization, throttling, and cache invalidation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
220 lines
7.5 KiB
PHP
220 lines
7.5 KiB
PHP
<?php
|
|
|
|
use App\Models\Declaration;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Notifications\NudgeNotification;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
|
|
function setupNudgeTest(string $senderRole = 'owner'): array
|
|
{
|
|
$sender = User::factory()->create();
|
|
$worker = User::factory()->create();
|
|
$workspace = Workspace::factory()->create();
|
|
|
|
$workspace->users()->attach($sender->id, ['role' => $senderRole, 'permissions' => []]);
|
|
$workspace->users()->attach($worker->id, ['role' => 'worker', 'permissions' => []]);
|
|
|
|
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
|
'assigned_to' => $worker->id,
|
|
]);
|
|
|
|
session(['current_workspace_id' => $workspace->id]);
|
|
|
|
return [$sender, $worker, $workspace, $declaration];
|
|
}
|
|
|
|
// ── AC2, AC5, AC7: Owner can send nudge ──────────────────
|
|
|
|
test('owner can send nudge — notification dispatched, activity logged, success flash', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHas('flash', [
|
|
'type' => 'success',
|
|
'message' => 'Relance envoyée à '.$worker->name,
|
|
]);
|
|
|
|
Notification::assertSentTo($worker, NudgeNotification::class);
|
|
});
|
|
|
|
// ── AC2, AC5: Manager can send nudge ─────────────────────
|
|
|
|
test('manager can send nudge — same behavior as owner', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('manager');
|
|
|
|
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHas('flash', [
|
|
'type' => 'success',
|
|
'message' => 'Relance envoyée à '.$worker->name,
|
|
]);
|
|
|
|
Notification::assertSentTo($worker, NudgeNotification::class);
|
|
});
|
|
|
|
// ── AC5: Worker cannot send nudge ────────────────────────
|
|
|
|
test('worker cannot send nudge — abort 404', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
$response = $this->actingAs($worker)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertNotFound();
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
// ── AC5: Nudge on declaration in wrong workspace ─────────
|
|
|
|
test('nudge on declaration in wrong workspace — abort 404', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
// Create a different workspace and set it as current
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
$otherWorkspace->users()->attach($sender->id, ['role' => 'owner', 'permissions' => []]);
|
|
session(['current_workspace_id' => $otherWorkspace->id]);
|
|
|
|
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertNotFound();
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
// ── Nudge on declaration with no assignee ────────────────
|
|
|
|
test('nudge on declaration with no assignee — returns error', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
$declaration->update(['assigned_to' => null]);
|
|
|
|
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHas('flash', function (array $flash) {
|
|
return $flash['type'] === 'warning';
|
|
});
|
|
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
// ── AC8: Duplicate nudge within 1 hour — debounce ────────
|
|
|
|
test('duplicate nudge within 1 hour — debounce prevents, returns warning flash', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
// Create a recent nudge notification manually in the database
|
|
$worker->notifications()->create([
|
|
'id' => \Illuminate\Support\Str::uuid(),
|
|
'type' => NudgeNotification::class,
|
|
'data' => [
|
|
'workspace_id' => $workspace->id,
|
|
'declaration_id' => $declaration->id,
|
|
'sender_id' => $sender->id,
|
|
],
|
|
'created_at' => now()->subMinutes(30),
|
|
]);
|
|
|
|
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHas('flash', [
|
|
'type' => 'warning',
|
|
'message' => 'Relance déjà envoyée récemment',
|
|
]);
|
|
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
// ── AC8: Nudge after 1 hour — allowed ────────────────────
|
|
|
|
test('nudge after 1 hour — allowed (debounce expired)', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
// Create an old nudge notification (more than 1 hour ago)
|
|
$worker->notifications()->create([
|
|
'id' => \Illuminate\Support\Str::uuid(),
|
|
'type' => NudgeNotification::class,
|
|
'data' => [
|
|
'workspace_id' => $workspace->id,
|
|
'declaration_id' => $declaration->id,
|
|
'sender_id' => $sender->id,
|
|
],
|
|
'created_at' => now()->subMinutes(61),
|
|
]);
|
|
|
|
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHas('flash', [
|
|
'type' => 'success',
|
|
'message' => 'Relance envoyée à '.$worker->name,
|
|
]);
|
|
|
|
Notification::assertSentTo($worker, NudgeNotification::class);
|
|
});
|
|
|
|
// ── AC7: Activity log entry created ──────────────────────
|
|
|
|
test('activity log entry created with correct actor/target/action', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
$this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
$activity = Activity::query()
|
|
->where('subject_type', Declaration::class)
|
|
->where('subject_id', $declaration->id)
|
|
->where('causer_id', $sender->id)
|
|
->where('description', 'nudged')
|
|
->first();
|
|
|
|
expect($activity)->not->toBeNull();
|
|
expect($activity->causer_id)->toBe($sender->id);
|
|
expect($activity->subject_id)->toBe($declaration->id);
|
|
});
|
|
|
|
// ── Unauthenticated user cannot nudge ────────────────────
|
|
|
|
test('unauthenticated user cannot nudge — redirected to login', function () {
|
|
$declaration = Declaration::factory()->create();
|
|
|
|
$response = $this->post(route('declarations.nudge', $declaration));
|
|
|
|
$response->assertRedirect(route('login'));
|
|
});
|
|
|
|
// ── Cache cleared for assignee after nudge ───────────────
|
|
|
|
test('notification cache cleared for assignee after nudge', function () {
|
|
Notification::fake();
|
|
|
|
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
|
|
|
// Pre-populate cache
|
|
Cache::put("user:{$worker->id}:workspace:{$workspace->id}:unread_notifications", 5);
|
|
|
|
$this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
|
|
|
expect(Cache::has("user:{$worker->id}:workspace:{$workspace->id}:unread_notifications"))->toBeFalse();
|
|
});
|