notifications() ->where('type', FindingEventNotification::class) ->orderBy('id') ->get(); } it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $service = app(FindingNotificationService::class); $assignee = User::factory()->create(['name' => 'Assignee']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => Finding::STATUS_TRIAGED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), 'due_at' => now()->addHours(6), ]); $service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED); $service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED); $service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON); $service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE); expect(dispatchedFindingNotificationsFor($assignee) ->pluck('data.finding_event.event_type') ->all()) ->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON); expect(dispatchedFindingNotificationsFor($owner) ->pluck('data.finding_event.event_type') ->all()) ->toContain(AlertRule::EVENT_FINDINGS_OVERDUE); $fallbackFinding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => Finding::STATUS_REOPENED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => null, 'due_at' => now()->addHours(2), ]); $service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED); $service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON); $ownerEventTypes = dispatchedFindingNotificationsFor($owner) ->pluck('data.finding_event.event_type') ->all(); expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON); }); it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Removed Assignee']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => Finding::STATUS_REOPENED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), ]); TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('user_id', (int) $assignee->getKey()) ->delete(); app(CapabilityResolver::class)->clearCache(); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED); expect($result['direct_delivery_status'])->toBe('suppressed') ->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0) ->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0); }); it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Current Assignee']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => Finding::STATUS_TRIAGED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), 'due_at' => now()->addHours(3), ]); $resolver = \Mockery::mock(CapabilityResolver::class); $resolver->shouldReceive('isMember')->andReturnTrue(); $resolver->shouldReceive('can') ->andReturnUsing(function (User $user): bool { return $user->name !== 'Current Assignee'; }); app()->instance(CapabilityResolver::class, $resolver); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON); expect($result['direct_delivery_status'])->toBe('suppressed') ->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0) ->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0); }); it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Assigned Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $replacementOwner = User::factory()->create(['name' => 'Replacement Owner']); createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager'); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => Finding::STATUS_TRIAGED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), ]); $workflow = app(FindingWorkflowService::class); $workflow->assign( finding: $finding, tenant: $tenant, actor: $owner, assigneeUserId: (int) $assignee->getKey(), ownerUserId: (int) $replacementOwner->getKey(), ); $workflow->assign( finding: $finding->fresh(), tenant: $tenant, actor: $owner, assigneeUserId: null, ownerUserId: (int) $replacementOwner->getKey(), ); expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0) ->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0) ->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0); }); it('suppresses due notifications for terminal findings', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $finding = Finding::factory()->for($tenant)->closed()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $owner->getKey(), 'due_at' => now()->subHour(), ]); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE); expect($result['direct_delivery_status'])->toBe('suppressed') ->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0); }); it('sends one direct notification when owner and assignee are the same entitled user', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => Finding::STATUS_IN_PROGRESS, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $owner->getKey(), 'due_at' => now()->subHour(), ]); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE); expect($result['direct_delivery_status'])->toBe('sent') ->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1) ->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner'); });