state)->toBe('no_tenant') ->and($stats->message)->toContain('No tenant'); }); it('returns no_assignment state when tenant has no baseline assignment', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $stats = BaselineCompareStats::forTenant($tenant); expect($stats->state)->toBe('no_assignment') ->and($stats->profileName)->toBeNull(); }); it('returns no_snapshot state when profile has no active snapshot', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, 'active_snapshot_id' => null, ]); BaselineTenantAssignment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), ]); $stats = BaselineCompareStats::forTenant($tenant); expect($stats->state)->toBe('no_snapshot') ->and($stats->profileName)->toBe($profile->name); }); it('returns comparing state when a run is queued', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, ]); $snapshot = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'baseline_profile_id' => $profile->getKey(), ]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]); BaselineTenantAssignment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), ]); OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'type' => 'baseline_compare', 'status' => 'queued', 'outcome' => 'pending', ]); $stats = BaselineCompareStats::forTenant($tenant); expect($stats->state)->toBe('comparing') ->and($stats->operationRunId)->not->toBeNull(); }); it('returns failed state when the latest run has failed outcome', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, ]); $snapshot = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'baseline_profile_id' => $profile->getKey(), ]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]); BaselineTenantAssignment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), ]); OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'type' => 'baseline_compare', 'status' => 'completed', 'outcome' => 'failed', 'failure_summary' => ['message' => 'Graph API timeout'], 'completed_at' => now()->subHour(), ]); $stats = BaselineCompareStats::forTenant($tenant); expect($stats->state)->toBe('failed') ->and($stats->failureReason)->toBe('Graph API timeout') ->and($stats->operationRunId)->not->toBeNull() ->and($stats->lastComparedHuman)->not->toBeNull() ->and($stats->lastComparedIso)->not->toBeNull(); }); it('returns ready state with grouped severity counts', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, ]); $snapshot = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'baseline_profile_id' => $profile->getKey(), ]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]); BaselineTenantAssignment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), ]); $scopeKey = 'baseline_profile:'.$profile->getKey(); Finding::factory()->count(2)->create([ 'workspace_id' => $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_NEW, ]); Finding::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'severity' => Finding::SEVERITY_MEDIUM, 'status' => Finding::STATUS_NEW, ]); Finding::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'severity' => Finding::SEVERITY_LOW, 'status' => Finding::STATUS_NEW, ]); // Terminal finding should not be counted in "open" drift totals. Finding::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_RESOLVED, ]); OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'type' => 'baseline_compare', 'status' => 'completed', 'outcome' => 'succeeded', 'completed_at' => now()->subHours(2), ]); $stats = BaselineCompareStats::forTenant($tenant); expect($stats->state)->toBe('ready') ->and($stats->findingsCount)->toBe(4) ->and($stats->severityCounts)->toBe([ 'high' => 2, 'medium' => 1, 'low' => 1, ]) ->and($stats->lastComparedHuman)->not->toBeNull() ->and($stats->lastComparedIso)->toContain('T'); }); it('returns idle state when profile is ready but no run exists yet', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, ]); $snapshot = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'baseline_profile_id' => $profile->getKey(), ]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]); BaselineTenantAssignment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), ]); $stats = BaselineCompareStats::forTenant($tenant); expect($stats->state)->toBe('idle') ->and($stats->profileName)->toBe($profile->name) ->and($stats->snapshotId)->toBe((int) $snapshot->getKey()); }); it('forWidget returns grouped severity counts for new findings only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, ]); BaselineTenantAssignment::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), ]); $scopeKey = 'baseline_profile:'.$profile->getKey(); // New finding (should be counted) Finding::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_NEW, ]); // Resolved finding (should NOT be counted) Finding::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_RESOLVED, ]); $stats = BaselineCompareStats::forWidget($tenant); expect($stats->findingsCount)->toBe(1) ->and($stats->severityCounts['high'])->toBe(1); });