TenantAtlas/tests/Feature/System/Spec114/OpsTriageActionsTest.php
ahmido fdd3a85b64 feat: align system operations surfaces (#201)
## Summary
- align the system-panel Operations, Failed operations, and Stuck operations pages to the read-only registry contract by removing inline row triage and keeping row-click inspection
- keep retry, cancel, and mark-investigated behavior on the canonical system operation detail page while adding the explicit `Show all operations` return path and updated `Operations / Operation` copy
- add and update focused Pest and Livewire coverage for list CTA behavior, detail-owned triage, and view-only versus manage-capable platform access
- add Spec 170 implementation artifacts plus the follow-on Spec 171 and Spec 172 packages

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php`
- integrated browser smoke on `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, empty states via search filter, and detail-page retry confirmation visibility

## Notes
- branch pushed from `170-system-operations-surface-alignment`
- latest commit: `64b4d741 feat: align system operations surfaces`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #201
2026-03-30 19:08:56 +00:00

318 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Failures;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Filament\System\Pages\Ops\Runs;
use App\Filament\System\Pages\Ops\Stuck;
use App\Filament\System\Pages\Ops\ViewRun;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\System\SystemOperationRunLinks;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
]);
});
afterEach(function () {
CarbonImmutable::setTestNow();
});
it('keeps the operations list scan-first with one go to runbooks cta', function () {
$run = OperationRun::factory()->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'type' => 'inventory_sync',
]);
$viewOnlyUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($viewOnlyUser, 'platform');
$livewire = Livewire::test(Runs::class)
->assertCanSeeTableRecords([$run])
->assertActionVisible('go_to_runbooks')
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'));
$table = $livewire->instance()->getTable();
$emptyStateActions = collect($table->getEmptyStateActions())
->map(fn (Action $action): array => [
'name' => $action->getName(),
'label' => $action->getLabel(),
'url' => $action->getUrl(),
])
->values()
->all();
expect($livewire->instance()->getTitle())->toBe('Operations')
->and($table->getActions())->toBeEmpty()
->and($table->getBulkActions())->toBeEmpty()
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run))
->and($emptyStateActions)->toBe([
[
'name' => 'go_to_runbooks_empty',
'label' => 'Go to runbooks',
'url' => Runbooks::getUrl(panel: 'system'),
],
]);
});
it('keeps the failed operations list scan-first with one show all operations cta', function () {
$run = OperationRun::factory()->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'type' => 'inventory_sync',
]);
$viewOnlyUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($viewOnlyUser, 'platform');
$livewire = Livewire::test(Failures::class)
->assertCanSeeTableRecords([$run])
->assertActionVisible('show_all_operations')
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index());
$table = $livewire->instance()->getTable();
$emptyStateActions = collect($table->getEmptyStateActions())
->map(fn (Action $action): array => [
'name' => $action->getName(),
'label' => $action->getLabel(),
'url' => $action->getUrl(),
])
->values()
->all();
expect($livewire->instance()->getTitle())->toBe('Failed operations')
->and($table->getActions())->toBeEmpty()
->and($table->getBulkActions())->toBeEmpty()
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run))
->and($emptyStateActions)->toBe([
[
'name' => 'show_all_operations_empty',
'label' => 'Show all operations',
'url' => SystemOperationRunLinks::index(),
],
]);
});
it('keeps the stuck operations list scan-first with one show all operations cta', function () {
config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10);
config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-27 10:00:00'));
$run = OperationRun::factory()->create([
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'type' => 'inventory_sync',
'created_at' => now()->subMinutes(30),
'started_at' => null,
]);
$viewOnlyUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($viewOnlyUser, 'platform');
$livewire = Livewire::test(Stuck::class)
->assertCanSeeTableRecords([$run])
->assertActionVisible('show_all_operations')
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index());
$table = $livewire->instance()->getTable();
$emptyStateActions = collect($table->getEmptyStateActions())
->map(fn (Action $action): array => [
'name' => $action->getName(),
'label' => $action->getLabel(),
'url' => $action->getUrl(),
])
->values()
->all();
expect($livewire->instance()->getTitle())->toBe('Stuck operations')
->and($table->getActions())->toBeEmpty()
->and($table->getBulkActions())->toBeEmpty()
->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run))
->and($emptyStateActions)->toBe([
[
'name' => 'show_all_operations_empty',
'label' => 'Show all operations',
'url' => SystemOperationRunLinks::index(),
],
]);
});
it('keeps detail-page triage, return paths, and audit behavior for manage operators', function () {
NotificationFacade::fake();
$failedRun = OperationRun::factory()->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'type' => 'inventory_sync',
]);
$runningRun = OperationRun::factory()->create([
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'type' => 'inventory_sync',
'created_at' => now()->subMinutes(15),
'started_at' => now()->subMinutes(10),
]);
$manageUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPERATIONS_VIEW,
PlatformCapabilities::OPERATIONS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($manageUser, 'platform');
$failedRunView = Livewire::test(ViewRun::class, [
'run' => $failedRun,
])
->assertActionVisible('show_all_operations')
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index())
->assertActionVisible('go_to_runbooks')
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'))
->assertActionVisible('retry')
->assertActionExists('retry', fn (Action $action): bool => $action->getLabel() === 'Retry' && $action->isConfirmationRequired())
->assertActionVisible('mark_investigated')
->assertActionExists('mark_investigated', fn (Action $action): bool => $action->getLabel() === 'Mark investigated' && $action->isConfirmationRequired())
->assertActionHidden('cancel');
expect($failedRunView->instance()->getTitle())->toBe('Operation #'.(int) $failedRun->getKey());
Livewire::test(ViewRun::class, [
'run' => $failedRun,
])
->callAction('retry')
->assertHasNoActionErrors()
->assertNotified('Inventory sync queued');
NotificationFacade::assertNothingSent();
expect(DatabaseNotification::query()->count())->toBe(0);
$retriedRun = OperationRun::query()
->whereKeyNot((int) $failedRun->getKey())
->latest('id')
->first();
expect($retriedRun)->not->toBeNull();
expect((string) $retriedRun?->status)->toBe(OperationRunStatus::Queued->value);
expect((int) data_get($retriedRun?->context, 'triage.retry_of_run_id'))->toBe((int) $failedRun->getKey());
$this->get(SystemOperationRunLinks::view($retriedRun))
->assertSuccessful()
->assertSee('Operation #'.(int) $retriedRun?->getKey())
->assertSee('Show all operations')
->assertSee('Go to runbooks');
Livewire::test(ViewRun::class, [
'run' => $failedRun,
])
->callAction('mark_investigated', data: [
'reason' => 'Checked by platform operations',
])
->assertHasNoActionErrors()
->assertNotified('Run marked as investigated');
Livewire::test(ViewRun::class, [
'run' => $runningRun,
])
->assertActionVisible('show_all_operations')
->assertActionVisible('go_to_runbooks')
->assertActionHidden('retry')
->assertActionVisible('cancel')
->assertActionExists('cancel', fn (Action $action): bool => $action->getLabel() === 'Cancel' && $action->isConfirmationRequired())
->assertActionVisible('mark_investigated')
->callAction('cancel')
->assertHasNoActionErrors()
->assertNotified('Run cancelled');
expect(AuditLog::query()->where('action', 'platform.system_console.retry')->exists())->toBeTrue();
expect(AuditLog::query()->where('action', 'platform.system_console.cancel')->exists())->toBeTrue();
expect(AuditLog::query()->where('action', 'platform.system_console.mark_investigated')->exists())->toBeTrue();
$runningRun->refresh();
expect((string) $runningRun->status)->toBe(OperationRunStatus::Completed->value)
->and((string) $runningRun->outcome)->toBe(OperationRunOutcome::Failed->value);
});
it('keeps detail inspection and navigation available while hiding triage for view-only operators', function () {
$run = OperationRun::factory()->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'type' => 'inventory_sync',
]);
$viewOnlyUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($viewOnlyUser, 'platform');
Livewire::test(ViewRun::class, [
'run' => $run,
])
->assertActionVisible('show_all_operations')
->assertActionExists('show_all_operations', fn (Action $action): bool => $action->getLabel() === 'Show all operations' && $action->getUrl() === SystemOperationRunLinks::index())
->assertActionVisible('go_to_runbooks')
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'))
->assertActionHidden('retry')
->assertActionHidden('cancel')
->assertActionHidden('mark_investigated');
$this->get(SystemOperationRunLinks::view($run))
->assertSuccessful()
->assertSee('Operation #'.(int) $run->getKey())
->assertSee('Show all operations')
->assertSee('Go to runbooks');
});