shouldReceive('build')->andReturn([ 'overview' => array_replace_recursive([ 'overall' => 'ready', 'counts' => [ 'missing_application' => 0, 'missing_delegated' => 0, ], 'freshness' => [ 'is_stale' => false, 'last_refreshed_at' => now()->toIso8601String(), ], ], $overview), ]); }); } /** * @return list */ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpression): array { $dom = new \DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML($content); libxml_clear_errors(); $xpath = new \DOMXPath($dom); $nodes = $xpath->query($xpathExpression); if ($nodes === false) { return []; } return collect(iterator_to_array($nodes)) ->map(static fn (\DOMNode $node): string => (string) $node->attributes?->getNamedItem('class')?->nodeValue) ->filter() ->values() ->all(); } it('builds the canonical operations follow-up baseline with tenant continuity', function (): void { $tenant = ManagedEnvironment::factory()->create(); expect(OperationRunLinks::index($tenant, activeTab: 'active')) ->toBe(route('admin.operations.index', [ 'managed_environment_id' => (int) $tenant->getKey(), 'activeTab' => 'active', ])) ->and(OperationRunLinks::index( $tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, )) ->toBe(route('admin.operations.index', [ 'managed_environment_id' => (int) $tenant->getKey(), 'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, 'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, ])); }); it('builds the required-permissions follow-up baseline with tenant continuity', function (): void { $tenant = ManagedEnvironment::factory()->create([ 'external_id' => 'tenant-dashboard-productization', ]); expect(RequiredPermissionsLinks::requiredPermissions($tenant, ['source' => 'tenant_dashboard'])) ->toBe('/admin/tenants/'.urlencode((string) $tenant->external_id).'/required-permissions?source=tenant_dashboard'); }); it('prioritizes operations requiring attention below permissions and high severity findings and keeps canonical hub links', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); mockTenantDashboardActionPermissions([ 'overall' => 'blocked', 'counts' => [ 'missing_application' => 1, 'missing_delegated' => 0, ], ]); Finding::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_NEW, ]); $run = OperationRun::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'type' => 'inventory_sync', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Failed->value, 'created_at' => now()->subMinute(), 'started_at' => now()->subMinutes(2), 'completed_at' => now()->subMinute(), ]); $summary = app(TenantDashboardSummaryBuilder::class) ->build($tenant, $user) ->toArray(); $activeOperationSummary = $summary['activeOperationSummary'] ?? null; $recommendedActions = $summary['recommendedActions'] ?? []; expect($activeOperationSummary) ->not->toBeNull() ->and($activeOperationSummary['items'][0]['primaryActionLabel'] ?? null)->toBe('Review operation') ->and($activeOperationSummary['items'][0]['primaryActionUrl'] ?? null)->toBe(OperationRunLinks::view($run, $tenant)) ->and($activeOperationSummary['secondaryActionLabel'] ?? null)->toBe('Open operations hub') ->and($activeOperationSummary['secondaryActionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)) ->and(array_column($recommendedActions, 'key'))->toBe([ 'required_permissions', 'high_severity_findings', 'operations_requiring_attention', ]) ->and($recommendedActions[2]['title'] ?? null)->toBe('Review operations requiring attention') ->and($recommendedActions[2]['reason'] ?? null)->toBe('One or more operations finished with an outcome that needs follow-up.') ->and($recommendedActions[2]['impact'] ?? null)->toBe('The tenant should not be treated as fully healthy until the operation outcome has been reviewed.') ->and($recommendedActions[2]['actionLabel'] ?? null)->toBe('Review operations') ->and($recommendedActions[2]['actionUrl'] ?? null)->toBe(OperationRunLinks::index($tenant, activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)); }); it('uses review permissions as the top recommended-action CTA when permissions are the highest follow-up', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); mockTenantDashboardActionPermissions([ 'overall' => 'blocked', 'counts' => [ 'missing_application' => 2, 'missing_delegated' => 0, ], ]); $recommendedActions = app(TenantDashboardSummaryBuilder::class) ->build($tenant, $user) ->toArray()['recommendedActions']; expect($recommendedActions[0]['key'] ?? null)->toBe('required_permissions') ->and($recommendedActions[0]['title'] ?? null)->toBe('Review permissions') ->and($recommendedActions[0]['actionLabel'] ?? null)->toBe('Review permissions') ->and($recommendedActions[0]['actionUrl'] ?? null)->toBe(RequiredPermissionsLinks::requiredPermissions($tenant)); }); it('orders productized recommended actions by priority and caps the visible list at three repo-real CTAs', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); mockTenantDashboardActionPermissions([ 'overall' => 'blocked', 'counts' => [ 'missing_application' => 2, 'missing_delegated' => 0, ], ]); [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage()); $backupSet = workspaceOverviewSeedHealthyBackup($tenant); workspaceOverviewSeedRestoreHistory($tenant, $backupSet); Finding::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_NEW, ]); $riskFinding = Finding::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'severity' => Finding::SEVERITY_LOW, 'status' => Finding::STATUS_RISK_ACCEPTED, ]); FindingException::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'managed_environment_id' => (int) $tenant->getKey(), 'finding_id' => (int) $riskFinding->getKey(), 'requested_by_user_id' => (int) $user->getKey(), 'owner_user_id' => (int) $user->getKey(), 'approved_by_user_id' => (int) $user->getKey(), 'status' => FindingException::STATUS_ACTIVE, 'current_validity_state' => FindingException::VALIDITY_EXPIRED, 'request_reason' => 'Expired risk acceptance for productization ordering', 'approval_reason' => 'Approved for regression', 'requested_at' => now()->subDays(7), 'approved_at' => now()->subDays(6), 'effective_from' => now()->subDays(6), 'review_due_at' => now()->subDay(), 'expires_at' => now()->subDay(), 'evidence_summary' => ['reference_count' => 0], ]); $summary = app(TenantDashboardSummaryBuilder::class) ->build($tenant, $user) ->toArray(); $actions = $summary['recommendedActions']; expect(array_column($actions, 'key')) ->toBe(['required_permissions', 'high_severity_findings', 'risk_exceptions']) ->and(count($actions))->toBe(3) ->and(array_column($actions, 'icon'))->toBe([ 'heroicon-m-shield-exclamation', 'heroicon-m-shield-exclamation', 'heroicon-o-exclamation-triangle', ]) ->and($actions[0]['actionUrl'])->toBe(RequiredPermissionsLinks::requiredPermissions($tenant)) ->and($actions[1]['actionUrl'])->toBe(FindingResource::getUrl('index', [ 'tab' => 'needs_action', 'high_severity' => 1, ], panel: 'tenant', tenant: $tenant)) ->and($actions[2]['actionUrl'])->toBe(FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant)); $this->actingAs($user); setTenantPanelContext($tenant); $content = $this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) ->assertSuccessful() ->getContent(); $recommendedButtonClasses = tenantDashboardButtonClassesForXPath( $content, "//*[@data-testid='tenant-dashboard-recommended-action']//*[self::a or self::button][contains(@class, 'fi-btn')]", ); $asideButtonClasses = tenantDashboardButtonClassesForXPath( $content, "//*[@data-testid='tenant-dashboard-readiness-card']//*[self::a or self::button][contains(@class, 'fi-btn')]", ); $priorityMarkerClasses = tenantDashboardButtonClassesForXPath( $content, "//*[@data-testid='tenant-dashboard-recommended-action-priority']", ); expect(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBe(3) ->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action-icon"'))->toBe(3) ->and($content)->toContain('data-icon="heroicon-m-shield-exclamation"') ->and($content)->toContain('data-icon="heroicon-o-exclamation-triangle"') ->and($recommendedButtonClasses)->not->toBeEmpty() ->and($asideButtonClasses)->not->toBeEmpty() ->and(collect([...$recommendedButtonClasses, ...$asideButtonClasses])->contains(static fn (string $classes): bool => str_contains($classes, 'fi-outlined')))->toBeFalse() ->and(collect($priorityMarkerClasses)->every(static fn (string $classes): bool => str_contains($classes, 'border-gray-200') && str_contains($classes, 'bg-gray-50') && str_contains($classes, 'text-gray-700')))->toBeTrue(); }); it('assigns semantically distinct icons to overdue-findings and recovery-posture follow-ups', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); mockTenantDashboardActionPermissions(); [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage()); $backupSet = workspaceOverviewSeedHealthyBackup($tenant); workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'follow_up'); Finding::factory() ->for($tenant) ->overdueByHours() ->create([ 'workspace_id' => (int) $tenant->workspace_id, 'severity' => Finding::SEVERITY_LOW, 'status' => Finding::STATUS_NEW, ]); $actions = app(TenantDashboardSummaryBuilder::class) ->build($tenant, $user) ->toArray()['recommendedActions']; expect(collect($actions)->firstWhere('key', 'overdue_findings')['icon'] ?? null) ->toBe('heroicon-o-clock') ->and(collect($actions)->firstWhere('key', 'recovery_posture')['icon'] ?? null) ->toBe('heroicon-o-arrow-path-rounded-square'); }); it('keeps continue-review follow-up unavailable for readonly members who can only inspect review state', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); composeTenantReviewForTest($tenant, $user); $summary = app(TenantDashboardSummaryBuilder::class) ->build($tenant, $user) ->toArray(); $continueReview = collect($summary['recommendedActions'])->firstWhere('key', 'continue_review'); expect($continueReview) ->not->toBeNull() ->and($continueReview['actionDisabled'])->toBeTrue() ->and($continueReview['actionUrl'])->toBeNull() ->and($continueReview['helperText'])->toContain('continue the review'); });