notifications() ->where('type', FindingEventNotification::class) ->latest('id') ->first(); } function findingNotificationCountFor(User $user, string $eventType): int { return $user->notifications() ->where('type', FindingEventNotification::class) ->get() ->filter(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === $eventType) ->count(); } function runEvaluateAlertsForWorkspace(int $workspaceId): void { $operationRun = OperationRun::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => null, 'type' => 'alerts.evaluate', 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, ]); $job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId, (int) $operationRun->getKey()); $job->handle( app(\App\Services\Alerts\AlertDispatchService::class), app(\App\Services\OperationRunService::class), app(FindingNotificationService::class), ); } it('emits assignment notifications only when a new assignee is committed', function (): void { [$owner, $tenant] = $this->actingAsFindingOperator(); $firstAssignee = User::factory()->create(['name' => 'First Assignee']); createUserWithTenant(tenant: $tenant, user: $firstAssignee, role: 'operator'); $secondAssignee = User::factory()->create(['name' => 'Second Assignee']); createUserWithTenant(tenant: $tenant, user: $secondAssignee, role: 'operator'); $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [ 'owner_user_id' => (int) $owner->getKey(), ]); $workflow = app(FindingWorkflowService::class); $workflow->assign( finding: $finding, tenant: $tenant, actor: $owner, assigneeUserId: (int) $firstAssignee->getKey(), ownerUserId: (int) $owner->getKey(), ); $firstNotification = latestFindingNotificationFor($firstAssignee); expect($firstNotification)->not->toBeNull() ->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned') ->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED); $workflow->assign( finding: $finding->fresh(), tenant: $tenant, actor: $owner, assigneeUserId: (int) $firstAssignee->getKey(), ownerUserId: (int) $secondAssignee->getKey(), ); $workflow->assign( finding: $finding->fresh(), tenant: $tenant, actor: $owner, assigneeUserId: null, ownerUserId: (int) $secondAssignee->getKey(), ); expect(findingNotificationCountFor($firstAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1); $workflow->assign( finding: $finding->fresh(), tenant: $tenant, actor: $owner, assigneeUserId: (int) $secondAssignee->getKey(), ownerUserId: (int) $secondAssignee->getKey(), ); $secondNotification = latestFindingNotificationFor($secondAssignee); expect($secondNotification)->not->toBeNull() ->and(data_get($secondNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED) ->and(findingNotificationCountFor($secondAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1); }); it('dedupes repeated reopen dispatches for the same reopen occurrence', function (): void { $now = CarbonImmutable::parse('2026-04-22T09:30:00Z'); CarbonImmutable::setTestNow($now); [$owner, $tenant] = $this->actingAsFindingOperator(); $assignee = User::factory()->create(['name' => 'Assigned Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED, [ 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), ]); $reopened = app(FindingWorkflowService::class)->reopenBySystem( finding: $finding, tenant: $tenant, reopenedAt: $now, ); expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1); app(FindingNotificationService::class)->dispatch($reopened->fresh(), AlertRule::EVENT_FINDINGS_REOPENED); expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1); }); it('sends due soon and overdue notifications once per due cycle and resets when due_at changes', function (): void { $now = CarbonImmutable::parse('2026-04-22T10:00:00Z'); CarbonImmutable::setTestNow($now); [$owner, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenant->workspace_id; Filament::setTenant($tenant, true); $this->actingAs($owner); $assignee = User::factory()->create(['name' => 'Due Soon Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $dueSoonFinding = Finding::factory()->for($tenant)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_TRIAGED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), 'due_at' => $now->addHours(6), ]); $overdueFinding = Finding::factory()->for($tenant)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_IN_PROGRESS, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => null, 'due_at' => $now->subHours(2), ]); $closedFinding = Finding::factory()->for($tenant)->closed()->create([ 'workspace_id' => $workspaceId, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), 'due_at' => $now->subHours(1), ]); runEvaluateAlertsForWorkspace($workspaceId); runEvaluateAlertsForWorkspace($workspaceId); expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1) ->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1); expect($assignee->notifications() ->where('type', FindingEventNotification::class) ->get() ->contains(fn ($notification): bool => (int) data_get($notification->data, 'finding_event.finding_id') === (int) $closedFinding->getKey())) ->toBeFalse(); $dueSoonFinding->forceFill([ 'due_at' => $now->addHours(12), ])->save(); $overdueFinding->forceFill([ 'due_at' => $now->addDay()->subHour(), ])->save(); runEvaluateAlertsForWorkspace($workspaceId); expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(2) ->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1); $dueSoonFinding->forceFill([ 'due_at' => $now->addDays(5), ])->save(); CarbonImmutable::setTestNow($now->addDays(2)); $overdueFinding->forceFill([ 'due_at' => CarbonImmutable::now('UTC')->subHour(), ])->save(); runEvaluateAlertsForWorkspace($workspaceId); expect(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(2); });