TenantAtlas/apps/platform/tests/Feature/Dashboard/TenantDashboardProductizationActionsTest.php
ahmido 3aeb0d04b8 Auto: 266-tenant-dashboard-productization-v1 → platform-dev (#322)
Automated PR created by Copilot per user request. Branch pushed: 266-tenant-dashboard-productization-v1

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #322
2026-05-03 14:03:46 +00:00

246 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationRunLinks;
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
use function Pest\Laravel\mock;
function mockTenantDashboardActionPermissions(array $overview = []): void
{
mock(TenantRequiredPermissionsViewModelBuilder::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 = Tenant::factory()->create();
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
->toBe(route('admin.operations.index', [
'tenant_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', [
'tenant_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 = Tenant::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('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([
'tenant_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([
'tenant_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,
'tenant_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');
});