TenantAtlas/tests/Feature/Filament/EmptyStateConsistencyTest.php
ahmido 73a3a62451 Spec 122: Empty state consistency pass (#148)
## Summary
- unify empty-state UX across the six in-scope Filament list pages
- move empty-state ownership toward resource `table()` definitions while preserving existing RBAC behavior
- add focused Pest coverage for empty-state rendering, CTA outcomes, populated-state regression behavior, and action-surface compliance
- add the Spec 122 planning artifacts and product discovery documents used for this pass

## Changed surfaces
- `PolicyResource`
- `BackupSetResource`
- `RestoreRunResource`
- `BackupScheduleResource`
- `WorkspaceResource`
- `AlertDeliveryResource`

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/EmptyStateConsistencyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/CreateCtaPlacementTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/PolicySyncStartSurfaceTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetUiEnforcementTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Filament v5 / Livewire v4.0+ compliance is preserved.
- Panel provider registration remains unchanged in `bootstrap/providers.php`.
- No new globally searchable resources were added.
- Destructive actions were not introduced by this pass.
- Alert Deliveries is documented as the explicit no-header-action exemption for the empty-state CTA relocation rule.
- Manual light/dark visual QA evidence is still expected in the PR/review artifact set for the remaining checklist items (`T018`, `T025`).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #148
2026-03-08 02:17:51 +00:00

196 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Jobs\SyncPoliciesJob;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function getFeature122EmptyStateTable(Testable $component): Table
{
return $component->instance()->getTable();
}
function getFeature122EmptyStateAction(Testable $component, string $name): ?Action
{
foreach (getFeature122EmptyStateTable($component)->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function makeWorkspaceListComponent(string $role = 'owner'): Testable
{
$workspace = Workspace::factory()->create([
'archived_at' => now(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => $role,
]);
$user->forceFill([
'last_workspace_id' => (int) $workspace->getKey(),
])->save();
test()->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
return Livewire::test(ListWorkspaces::class);
}
it('defines the policies empty state contract and keeps the sync CTA outcome intact', function (): void {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync'])
->assertSee('No policies synced yet')
->assertSee('Sync your first tenant to see Intune policies here.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path');
$action = getFeature122EmptyStateAction($component, 'sync');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('Sync from Intune');
$component
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
});
it('defines the backup sets empty state contract and links its CTA to create', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListBackupSets::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No backup sets')
->assertSee('Create a backup set to start protecting your configurations.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No backup sets');
expect($table->getEmptyStateDescription())->toBe('Create a backup set to start protecting your configurations.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-archive-box');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('Create backup set');
expect($action?->getUrl())->toBe(BackupSetResource::getUrl('create', tenant: $tenant));
});
it('defines the restore runs empty state contract and links its CTA to create', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListRestoreRuns::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No restore runs')
->assertSee('Start a restoration from a backup set.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No restore runs');
expect($table->getEmptyStateDescription())->toBe('Start a restoration from a backup set.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path-rounded-square');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('New restore run');
expect($action?->getUrl())->toBe(RestoreRunResource::getUrl('create', tenant: $tenant));
});
it('defines the backup schedules empty state contract and links its CTA to create', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListBackupSchedules::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No schedules configured')
->assertSee('Set up automated backups.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No schedules configured');
expect($table->getEmptyStateDescription())->toBe('Set up automated backups.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-clock');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('New backup schedule');
expect($action?->getUrl())->toBe(BackupScheduleResource::getUrl('create', tenant: $tenant));
});
it('defines the workspaces empty state contract and links its CTA to create', function (): void {
$component = makeWorkspaceListComponent()
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No workspaces')
->assertSee('Create your first workspace.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No workspaces');
expect($table->getEmptyStateDescription())->toBe('Create your first workspace.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-squares-2x2');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('New workspace');
expect($action?->getUrl())->toBe(WorkspaceResource::getUrl('create'));
});