TenantAtlas/tests/Feature/Guards/ActionSurfaceValidatorTest.php
ahmido a770b32e87 feat: action-surface contract inspect affordance + clickable rows (#100)
Implements Spec 082 updates to the Filament Action Surface Contract:

- New required list/table slot: InspectAffordance (clickable row via recordUrl preferred; also supports View action or primary link column)
- Retrofit view-only tables to remove lone View row action buttons and use clickable rows
- Update validator + guard tests, add golden regression assertions
- Add docs: docs/ui/action-surface-contract.md

Tests (local via Sail):
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupSyncRunResourceTest.php

Notes:
- Filament v5 / Livewire v4 compatible.
- No destructive-action behavior changed in this PR.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #100
2026-02-08 20:31:36 +00:00

154 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceSlotRequirement;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidatorCompleteStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorMissingSlotStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
}
}
final class ActionSurfaceValidatorRunLogNoExportStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorNoDeclarationStub {}
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
{
return new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Resource,
panelScopes: [ActionSurfacePanelScope::Tenant],
);
}
it('passes when all required slots are declared', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorCompleteStub::class)]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('fails when a required slot is missing', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSlotStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Required slot is not declared');
});
it('fails missing declarations when no baseline exemption exists', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
});
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([
ActionSurfaceValidatorNoDeclarationStub::class => 'Retrofit intentionally deferred in validator unit test.',
]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('requires a bulk exemption reason when run-log export default is disabled', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorRunLogNoExportStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('ReadOnly/RunLog profile disables Export default');
});
it('fails when slot exemption reason is missing', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorExemptSlotWithoutReasonStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
});