> */ function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array { $job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId); $reflection = new ReflectionMethod($job, 'slaDueEvents'); /** @var array> $events */ $events = $reflection->invoke($job, $workspaceId, $windowStart); return $events; } it('produces one sla due event per tenant and summarizes current overdue open findings', function (): void { $now = CarbonImmutable::parse('2026-02-24T12:00:00Z'); CarbonImmutable::setTestNow($now); [$user, $tenantA] = createUserWithTenant(role: 'owner'); $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); $tenantB = Tenant::factory()->create(['workspace_id' => $workspaceId]); $tenantC = Tenant::factory()->create(['workspace_id' => $workspaceId]); $windowStart = $now->subHour(); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenantA->getKey(), 'status' => Finding::STATUS_IN_PROGRESS, 'severity' => Finding::SEVERITY_CRITICAL, 'due_at' => $now->subMinutes(10), ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenantA->getKey(), 'status' => Finding::STATUS_TRIAGED, 'severity' => Finding::SEVERITY_HIGH, 'due_at' => $now->subDays(1), ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenantA->getKey(), 'status' => Finding::STATUS_NEW, 'severity' => Finding::SEVERITY_MEDIUM, 'due_at' => $windowStart, ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenantA->getKey(), 'status' => Finding::STATUS_RESOLVED, 'severity' => Finding::SEVERITY_LOW, 'due_at' => $now->subMinutes(5), ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenantB->getKey(), 'status' => Finding::STATUS_REOPENED, 'severity' => Finding::SEVERITY_HIGH, 'due_at' => $now->subDays(2), ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenantC->getKey(), 'status' => Finding::STATUS_NEW, 'severity' => Finding::SEVERITY_LOW, 'due_at' => $now->subMinutes(20), ]); $events = invokeSlaDueEvents($workspaceId, $windowStart); expect($events)->toHaveCount(2); $eventsByTenant = collect($events)->keyBy(static fn (array $event): int => (int) $event['tenant_id']); expect($eventsByTenant->keys()->all()) ->toEqualCanonicalizing([(int) $tenantA->getKey(), (int) $tenantC->getKey()]); $tenantAEvent = $eventsByTenant->get((int) $tenantA->getKey()); expect($tenantAEvent) ->not->toBeNull() ->and($tenantAEvent['event_type'])->toBe(AlertRule::EVENT_SLA_DUE) ->and($tenantAEvent['severity'])->toBe(Finding::SEVERITY_CRITICAL) ->and($tenantAEvent['metadata'])->toMatchArray([ 'overdue_total' => 3, 'overdue_by_severity' => [ 'critical' => 1, 'high' => 1, 'medium' => 1, 'low' => 0, ], ]) ->and($tenantAEvent['metadata'])->not->toHaveKey('finding_ids'); $tenantCEvent = $eventsByTenant->get((int) $tenantC->getKey()); expect($tenantCEvent) ->not->toBeNull() ->and($tenantCEvent['severity'])->toBe(Finding::SEVERITY_LOW) ->and($tenantCEvent['metadata'])->toMatchArray([ 'overdue_total' => 1, 'overdue_by_severity' => [ 'critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 1, ], ]); }); it('gates sla due events to newly overdue open findings after window start', function (): void { $now = CarbonImmutable::parse('2026-02-24T12:00:00Z'); CarbonImmutable::setTestNow($now); [$user, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); $windowStart = $now->subHour(); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->getKey(), 'status' => Finding::STATUS_NEW, 'severity' => Finding::SEVERITY_HIGH, 'due_at' => $now->subDays(1), ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->getKey(), 'status' => Finding::STATUS_NEW, 'severity' => Finding::SEVERITY_MEDIUM, 'due_at' => $windowStart, ]); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->getKey(), 'status' => Finding::STATUS_CLOSED, 'severity' => Finding::SEVERITY_CRITICAL, 'due_at' => $now->subMinutes(5), ]); expect(invokeSlaDueEvents($workspaceId, $windowStart))->toBe([]); }); it('uses a stable fingerprint per tenant and alert window', function (): void { $now = CarbonImmutable::parse('2026-02-24T12:00:00Z'); CarbonImmutable::setTestNow($now); [$user, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); Finding::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->getKey(), 'status' => Finding::STATUS_NEW, 'severity' => Finding::SEVERITY_HIGH, 'due_at' => $now->subMinute(), ]); $windowA = $now->subMinutes(5); $windowB = $now->subMinutes(2); $first = invokeSlaDueEvents($workspaceId, $windowA); $second = invokeSlaDueEvents($workspaceId, $windowA); $third = invokeSlaDueEvents($workspaceId, $windowB); expect($first)->toHaveCount(1) ->and($second)->toHaveCount(1) ->and($third)->toHaveCount(1) ->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key']) ->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']); });