TenantAtlas/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php
Ahmed Darrazi 69a9fb6796
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m4s
feat: environment dashboard operator guidance consolidation (spec 352)
Implemented the consolidated operator guidance panel for the environment dashboard. Updated EnvironmentDashboardSummaryBuilder to prioritize and select guidance based on the operator guidance contract. Added comprehensive unit, feature, and browser tests to verify the guidance selection logic and UI rendering.
2026-06-04 14:55:20 +02:00

329 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use function Pest\Laravel\mock;
function mockEnvironmentDashboardActionPermissions(array $overview = []): void
{
mock(ManagedEnvironmentRequiredPermissionsViewModelBuilder::class, function ($mock) use ($overview): void {
$mock->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<string>
*/
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();
[, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
->toBe(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'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', [
'workspace' => $tenant->workspace,
'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',
]);
[, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
expect(RequiredPermissionsLinks::requiredPermissions($tenant, ['source' => 'tenant_dashboard']))
->toBe(url('/admin/workspaces/'.urlencode((string) $tenant->workspace->slug).'/environments/'.urlencode((string) $tenant->getRouteKey()).'/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');
mockEnvironmentDashboardActionPermissions([
'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(EnvironmentDashboardSummaryBuilder::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 environment 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');
mockEnvironmentDashboardActionPermissions([
'overall' => 'blocked',
'counts' => [
'missing_application' => 2,
'missing_delegated' => 0,
],
]);
$recommendedActions = app(EnvironmentDashboardSummaryBuilder::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');
mockEnvironmentDashboardActionPermissions([
'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(EnvironmentDashboardSummaryBuilder::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: 'admin', tenant: $tenant))
->and($actions[2]['actionUrl'])->toBe(FindingExceptionResource::getUrl('index', panel: 'admin', tenant: $tenant));
$this->actingAs($user);
setAdminEnvironmentContext($tenant);
$content = $this->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()
->getContent();
$compactActionLinkClasses = tenantDashboardButtonClassesForXPath(
$content,
"//*[@data-recommended-actions-style='compact']//*[@data-testid='tenant-dashboard-secondary-action']",
);
$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($content)->toContain('Additional follow-ups')
->and(substr_count($content, 'data-recommended-actions-style="compact"'))->toBe(1)
->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action"'))->toBe(2)
->and(substr_count($content, 'data-testid="tenant-dashboard-recommended-action-icon"'))->toBe(2)
->and($content)->toContain('data-icon="heroicon-m-shield-exclamation"')
->and($content)->toContain('data-icon="heroicon-o-exclamation-triangle"')
->and($compactActionLinkClasses)->not->toBeEmpty()
->and($asideButtonClasses)->toBe([])
->and(collect($compactActionLinkClasses)->every(static fn (string $classes): bool => str_contains($classes, 'text-primary-600')
&& ! str_contains($classes, 'fi-btn')))->toBeTrue()
->and($priorityMarkerClasses)->toBe([]);
});
it('assigns semantically distinct icons to overdue-findings and recovery-posture follow-ups', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
mockEnvironmentDashboardActionPermissions();
[$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(EnvironmentDashboardSummaryBuilder::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');
composeEnvironmentReviewForTest($tenant, $user);
$summary = app(EnvironmentDashboardSummaryBuilder::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');
});