Implements Spec 087: Legacy Runs Removal (rigorous). ### What changed - Canonicalized run history: **`operation_runs` is the only run system** for inventory sync, Entra group sync, backup schedule execution/retention/purge. - Removed legacy UI surfaces (Filament Resources / relation managers) for legacy run models. - Legacy run URLs now return **404** (no redirects), with RBAC semantics preserved (404 vs 403 as specified). - Canonicalized affected `operation_runs.type` values (dotted → underscore) via migration. - Drift + inventory references now point to canonical operation runs; includes backfills and then drops legacy FK columns. - Drops legacy run tables after cutover. - Added regression guards to prevent reintroducing legacy run tokens or “backfilling” canonical runs from legacy tables. ### Migrations - `2026_02_12_000001..000006_*` canonicalize types, add/backfill operation_run_id references, drop legacy columns, and drop legacy run tables. ### Tests Focused pack for this spec passed: - `tests/Feature/Guards/NoLegacyRunsTest.php` - `tests/Feature/Guards/NoLegacyRunBackfillTest.php` - `tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php` - `tests/Feature/Monitoring/MonitoringOperationsTest.php` - `tests/Feature/Jobs/RunInventorySyncJobTest.php` ### Notes / impact - Destructive cleanup is handled via migrations (drops legacy tables) after code cutover; deploy should run migrations in the same release. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #106
185 lines
6.8 KiB
PHP
185 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\InventoryItemResource;
|
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
|
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\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('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);
|
|
});
|