TenantAtlas/tests/Feature/Guards/ActionSurfaceContractTest.php
Ahmed Darrazi 72faa38472 feat: require inspect affordance for lists
- Replace view-only row buttons with clickable rows (recordUrl)\n- Update action-surface contract slot to InspectAffordance + validator support\n- Add golden guard tests + contract doc\n- Update SpecKit constitution/templates to include inspection affordance rule
2026-02-08 21:29:20 +01:00

210 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\InventorySyncRunResource;
use App\Filament\Resources\InventorySyncRunResource\Pages\ListInventorySyncRuns;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\SyncPoliciesJob;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('passes the action surface contract guard for current repository state', function (): void {
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('excludes widgets from action surface discovery scope', function (): void {
$classes = array_map(
static fn ($component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
);
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
return str_starts_with($className, 'App\\Filament\\Widgets\\');
}));
expect($widgetClasses)->toBeEmpty();
});
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
$exemptions = ActionSurfaceExemptions::baseline();
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
});
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
expect($tenantResource)->not->toBeNull();
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
expect($policyResource)->not->toBeNull();
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
});
it('requires non-empty reasons for every baseline exemption', function (): void {
$reasons = ActionSurfaceExemptions::baseline()->all();
foreach ($reasons as $className => $reason) {
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
}
});
it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition;
$declarations = [
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
];
foreach ($declarations as $className => $declaration) {
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
expect($declaration->slot($slot))
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
}
}
});
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync']);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
expect($rowGroup?->getLabel())->toBe('More');
$primaryRowActionCount = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->count();
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$bulkActions = $table->getBulkActions();
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
expect($bulkGroup?->getLabel())->toBe('More');
});
it('uses canonical tenantless View run links on representative operation links', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
]);
expect(OperationRunLinks::view($run, $tenant))
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
});
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventoryItems::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($item);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
});
it('removes lone View buttons and uses clickable rows on the inventory sync runs list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventorySyncRuns::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($run);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventorySyncRunResource::getUrl('view', ['record' => $run]));
});
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
expect((string) $run?->initiator_name)->toBe((string) $user->name);
});