diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 3b7c1d6..d69c67c 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -11,6 +11,7 @@ use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; +use App\Notifications\RunStatusChangedNotification; use App\Services\BulkOperationService; use App\Services\Drift\DriftRunSelector; use App\Support\RunIdempotency; @@ -176,13 +177,24 @@ public function mount(): void return; } + if (! $user->canSyncTenant($tenant)) { + $this->state = 'blocked'; + $this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.'; + + return; + } + $bulkOperationService = app(BulkOperationService::class); $run = $bulkOperationService->createRun( tenant: $tenant, user: $user, resource: 'drift', action: 'generate', - itemIds: [$scopeKey], + itemIds: [ + 'scope_key' => $scopeKey, + 'baseline_run_id' => (int) $baseline->getKey(), + 'current_run_id' => (int) $current->getKey(), + ], totalItems: 1, ); @@ -199,6 +211,20 @@ public function mount(): void scopeKey: $scopeKey, bulkOperationRunId: (int) $run->getKey(), ); + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'bulk_operation', + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', + 'counts' => [ + 'total' => (int) $run->total_items, + 'processed' => (int) $run->processed_items, + 'succeeded' => (int) $run->succeeded, + 'failed' => (int) $run->failed, + 'skipped' => (int) $run->skipped, + ], + ])); } public function getFindingsUrl(): string diff --git a/app/Filament/Resources/BulkOperationRunResource.php b/app/Filament/Resources/BulkOperationRunResource.php index cfdde9b..ca9677e 100644 --- a/app/Filament/Resources/BulkOperationRunResource.php +++ b/app/Filament/Resources/BulkOperationRunResource.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use BackedEnum; use Filament\Actions; +use Filament\Forms\Components\DatePicker; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -25,7 +26,9 @@ class BulkOperationRunResource extends Resource protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; - protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; + protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; + + protected static ?string $navigationLabel = 'Operations'; public static function form(Schema $schema): Schema { @@ -44,8 +47,10 @@ public static function infolist(Schema $schema): Schema TextEntry::make('resource')->badge(), TextEntry::make('action')->badge(), TextEntry::make('status') + ->label('Outcome') ->badge() - ->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)), + ->state(fn (BulkOperationRun $record): string => $record->statusBucket()) + ->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())), TextEntry::make('total_items')->label('Total')->numeric(), TextEntry::make('processed_items')->label('Processed')->numeric(), TextEntry::make('succeeded')->numeric(), @@ -58,6 +63,86 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), + Section::make('Related') + ->schema([ + TextEntry::make('related_backup_set') + ->label('Backup set') + ->state(function (BulkOperationRun $record): ?string { + $backupSetId = static::backupSetIdFromItemIds($record); + + if (! $backupSetId) { + return null; + } + + return "#{$backupSetId}"; + }) + ->url(function (BulkOperationRun $record): ?string { + $backupSetId = static::backupSetIdFromItemIds($record); + + if (! $backupSetId) { + return null; + } + + return BackupSetResource::getUrl('view', ['record' => $backupSetId], tenant: Tenant::current()); + }) + ->visible(fn (BulkOperationRun $record): bool => static::backupSetIdFromItemIds($record) !== null) + ->placeholder('—') + ->columnSpanFull(), + TextEntry::make('related_drift_findings') + ->label('Drift findings') + ->state('View') + ->url(function (BulkOperationRun $record): ?string { + if ($record->runType() !== 'drift.generate') { + return null; + } + + $payload = $record->item_ids ?? []; + if (! is_array($payload)) { + return FindingResource::getUrl('index', tenant: Tenant::current()); + } + + $scopeKey = null; + $baselineRunId = null; + $currentRunId = null; + + if (array_is_list($payload) && isset($payload[0]) && is_string($payload[0])) { + $scopeKey = $payload[0]; + } else { + $scopeKey = is_string($payload['scope_key'] ?? null) ? $payload['scope_key'] : null; + + if (is_numeric($payload['baseline_run_id'] ?? null)) { + $baselineRunId = (int) $payload['baseline_run_id']; + } + + if (is_numeric($payload['current_run_id'] ?? null)) { + $currentRunId = (int) $payload['current_run_id']; + } + } + + $tableFilters = []; + + if (is_string($scopeKey) && $scopeKey !== '') { + $tableFilters['scope_key'] = ['scope_key' => $scopeKey]; + } + + if (is_int($baselineRunId) || is_int($currentRunId)) { + $tableFilters['run_ids'] = [ + 'baseline_run_id' => $baselineRunId, + 'current_run_id' => $currentRunId, + ]; + } + + $parameters = $tableFilters !== [] ? ['tableFilters' => $tableFilters] : []; + + return FindingResource::getUrl('index', $parameters, tenant: Tenant::current()); + }) + ->visible(fn (BulkOperationRun $record): bool => $record->runType() === 'drift.generate') + ->placeholder('—') + ->columnSpanFull(), + ]) + ->visible(fn (BulkOperationRun $record): bool => in_array($record->runType(), ['backup_set.add_policies', 'drift.generate'], true)) + ->columnSpanFull(), + Section::make('Items') ->schema([ ViewEntry::make('item_ids') @@ -97,13 +182,112 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('resource')->badge(), Tables\Columns\TextColumn::make('action')->badge(), Tables\Columns\TextColumn::make('status') + ->label('Outcome') ->badge() - ->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)), + ->formatStateUsing(fn (BulkOperationRun $record): string => $record->statusBucket()) + ->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())), Tables\Columns\TextColumn::make('created_at')->since(), Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(), Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(), Tables\Columns\TextColumn::make('failed')->numeric(), ]) + ->filters([ + Tables\Filters\SelectFilter::make('run_type') + ->label('Run type') + ->options(fn (): array => static::runTypeOptions()) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '' || ! str_contains($value, '.')) { + return $query; + } + + [$resource, $action] = explode('.', $value, 2); + + if ($resource === '' || $action === '') { + return $query; + } + + return $query + ->where('resource', $resource) + ->where('action', $action); + }), + Tables\Filters\SelectFilter::make('status_bucket') + ->label('Status') + ->options([ + 'queued' => 'Queued', + 'running' => 'Running', + 'succeeded' => 'Succeeded', + 'partially succeeded' => 'Partially succeeded', + 'failed' => 'Failed', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + $nonSkippedFailureSql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(failures, '[]'::jsonb)) AS elem WHERE (elem->>'type' IS NULL OR elem->>'type' <> 'skipped'))"; + + return match ($value) { + 'queued' => $query->where('status', 'pending'), + 'running' => $query->where('status', 'running'), + 'succeeded' => $query + ->whereIn('status', ['completed', 'completed_with_errors']) + ->where('failed', 0) + ->whereRaw("NOT {$nonSkippedFailureSql}"), + 'partially succeeded' => $query + ->whereNotIn('status', ['pending', 'running']) + ->where('succeeded', '>', 0) + ->where(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql); + }), + 'failed' => $query + ->whereNotIn('status', ['pending', 'running']) + ->where(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->where(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->where('succeeded', 0) + ->where(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql); + }); + })->orWhere(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->whereIn('status', ['failed', 'aborted']) + ->whereNot(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->where('succeeded', '>', 0) + ->where(function (Builder $q) use ($nonSkippedFailureSql): void { + $q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql); + }); + }); + }); + }), + default => $query, + }; + }), + Tables\Filters\Filter::make('created_at') + ->label('Created') + ->form([ + DatePicker::make('created_from') + ->label('From') + ->default(fn () => now()->subDays(30)), + DatePicker::make('created_until') + ->label('Until') + ->default(fn () => now()), + ]) + ->query(function (Builder $query, array $data): Builder { + $from = $data['created_from'] ?? null; + if ($from) { + $query->whereDate('created_at', '>=', $from); + } + + $until = $data['created_until'] ?? null; + if ($until) { + $query->whereDate('created_at', '<=', $until); + } + + return $query; + }), + ]) ->actions([ Actions\ViewAction::make(), ]) @@ -125,14 +309,65 @@ public static function getPages(): array ]; } - private static function statusColor(?string $status): string + /** + * @return array + */ + private static function runTypeOptions(): array { - return match ($status) { - 'completed' => 'success', - 'completed_with_errors' => 'warning', + $tenantId = Tenant::current()->getKey(); + + $knownTypes = [ + 'drift.generate' => 'drift.generate', + 'backup_set.add_policies' => 'backup_set.add_policies', + ]; + + $storedTypes = BulkOperationRun::query() + ->where('tenant_id', $tenantId) + ->select(['resource', 'action']) + ->distinct() + ->orderBy('resource') + ->orderBy('action') + ->get() + ->mapWithKeys(function (BulkOperationRun $run): array { + $type = "{$run->resource}.{$run->action}"; + + return [$type => $type]; + }) + ->all(); + + return array_replace($storedTypes, $knownTypes); + } + + private static function statusBucketColor(string $statusBucket): string + { + return match ($statusBucket) { + 'succeeded' => 'success', + 'partially succeeded' => 'warning', 'failed' => 'danger', 'running' => 'info', + 'queued' => 'gray', default => 'gray', }; } + + private static function backupSetIdFromItemIds(BulkOperationRun $record): ?int + { + if ($record->runType() !== 'backup_set.add_policies') { + return null; + } + + $payload = $record->item_ids ?? []; + if (! is_array($payload)) { + return null; + } + + $backupSetId = $payload['backup_set_id'] ?? null; + if (! is_numeric($backupSetId)) { + return null; + } + + $backupSetId = (int) $backupSetId; + + return $backupSetId > 0 ? $backupSetId : null; + } } diff --git a/app/Jobs/GenerateDriftFindingsJob.php b/app/Jobs/GenerateDriftFindingsJob.php index 353cb77..f5c75ec 100644 --- a/app/Jobs/GenerateDriftFindingsJob.php +++ b/app/Jobs/GenerateDriftFindingsJob.php @@ -5,6 +5,7 @@ use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Notifications\RunStatusChangedNotification; use App\Services\BulkOperationService; use App\Services\Drift\DriftFindingGenerator; use Illuminate\Bus\Queueable; @@ -86,6 +87,8 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b $bulkOperationService->recordSuccess($run); $bulkOperationService->complete($run); + + $this->notifyStatus($run->refresh()); } catch (Throwable $e) { Log::error('GenerateDriftFindingsJob: failed', [ 'tenant_id' => $this->tenantId, @@ -96,9 +99,86 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b 'error' => $e->getMessage(), ]); + $bulkOperationService->recordFailure( + run: $run, + itemId: $this->scopeKey, + reason: $e->getMessage(), + reasonCode: 'unknown', + ); + $bulkOperationService->fail($run, $e->getMessage()); + $this->notifyStatus($run->refresh()); throw $e; } } + + private function notifyStatus(BulkOperationRun $run): void + { + try { + if (! $run->relationLoaded('user')) { + $run->loadMissing('user'); + } + + if (! $run->user) { + return; + } + + $status = 'failed'; + + try { + $status = $run->statusBucket(); + } catch (Throwable) { + $failureEntries = $run->failures ?? []; + $hasNonSkippedFailure = false; + foreach ($failureEntries as $entry) { + if (! is_array($entry)) { + continue; + } + + if (($entry['type'] ?? 'failed') !== 'skipped') { + $hasNonSkippedFailure = true; + break; + } + } + + $failedCount = (int) ($run->failed ?? 0); + $succeededCount = (int) ($run->succeeded ?? 0); + $hasFailures = $failedCount > 0 || $hasNonSkippedFailure; + + if ($succeededCount > 0 && $hasFailures) { + $status = 'partially succeeded'; + } elseif ($succeededCount === 0 && $hasFailures) { + $status = 'failed'; + } else { + $status = match ($run->status) { + 'pending' => 'queued', + 'running' => 'running', + 'completed', 'completed_with_errors' => 'succeeded', + default => 'failed', + }; + } + } + + $run->user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $run->tenant_id, + 'run_type' => 'bulk_operation', + 'run_id' => (int) $run->getKey(), + 'status' => $status, + 'counts' => [ + 'total' => (int) $run->total_items, + 'processed' => (int) $run->processed_items, + 'succeeded' => (int) $run->succeeded, + 'failed' => (int) $run->failed, + 'skipped' => (int) $run->skipped, + ], + ])); + } catch (Throwable $e) { + Log::warning('GenerateDriftFindingsJob: status notification failed', [ + 'tenant_id' => (int) $run->tenant_id, + 'bulk_operation_run_id' => (int) $run->getKey(), + 'error' => $e->getMessage(), + ]); + } + } } diff --git a/app/Models/BulkOperationRun.php b/app/Models/BulkOperationRun.php index 5880dba..5e5d135 100644 --- a/app/Models/BulkOperationRun.php +++ b/app/Models/BulkOperationRun.php @@ -51,4 +51,54 @@ public function auditLog(): BelongsTo { return $this->belongsTo(AuditLog::class); } + + public function runType(): string + { + return "{$this->resource}.{$this->action}"; + } + + public function statusBucket(): string + { + $status = $this->status; + + if ($status === 'pending') { + return 'queued'; + } + + if ($status === 'running') { + return 'running'; + } + + $succeededCount = (int) ($this->succeeded ?? 0); + $failedCount = (int) ($this->failed ?? 0); + $failureEntries = $this->failures ?? []; + $hasNonSkippedFailure = false; + + foreach ($failureEntries as $entry) { + if (! is_array($entry)) { + continue; + } + + if (($entry['type'] ?? 'failed') !== 'skipped') { + $hasNonSkippedFailure = true; + break; + } + } + + $hasFailures = $failedCount > 0 || $hasNonSkippedFailure; + + if ($succeededCount > 0 && $hasFailures) { + return 'partially succeeded'; + } + + if ($succeededCount === 0 && $hasFailures) { + return 'failed'; + } + + return match ($status) { + 'completed', 'completed_with_errors' => 'succeeded', + 'failed', 'aborted' => 'failed', + default => 'failed', + }; + } } diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 0a0f6d0..37ae65b 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -44,7 +44,7 @@ public function toDatabase(object $notifiable): array 'queued' => 'Run queued', 'running' => 'Run started', 'completed', 'succeeded' => 'Run completed', - 'partial', 'completed_with_errors' => 'Run completed (partial)', + 'partial', 'partially succeeded', 'completed_with_errors' => 'Run completed (partial)', 'failed' => 'Run failed', default => 'Run updated', }; @@ -54,7 +54,7 @@ public function toDatabase(object $notifiable): array $color = match ($status) { 'queued', 'running' => 'gray', 'completed', 'succeeded' => 'success', - 'partial', 'completed_with_errors' => 'warning', + 'partial', 'partially succeeded', 'completed_with_errors' => 'warning', 'failed' => 'danger', default => 'gray', }; diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php index 09fc6fa..5807cc4 100644 --- a/tests/Feature/Drift/DriftGenerationDispatchTest.php +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -1,9 +1,11 @@ resource)->toBe('drift'); expect($bulkRun->action)->toBe('generate'); expect($bulkRun->status)->toBe('pending'); + expect($bulkRun->item_ids)->toBe([ + 'scope_key' => $scopeKey, + 'baseline_run_id' => (int) $baseline->getKey(), + 'current_run_id' => (int) $current->getKey(), + ]); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => RunStatusChangedNotification::class, + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $bulkRun->getKey()], tenant: $tenant)); Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool { return $job->tenantId === (int) $tenant->getKey() @@ -127,3 +145,30 @@ Queue::assertNothingPushed(); expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); + +test('opening Drift does not dispatch generation for readonly users', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $scopeKey = hash('sha256', 'scope-readonly-blocked'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + Livewire::test(DriftLanding::class); + + Queue::assertNothingPushed(); + expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); +}); diff --git a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php new file mode 100644 index 0000000..0100470 --- /dev/null +++ b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php @@ -0,0 +1,143 @@ +for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + $current = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'resource' => 'drift', + 'action' => 'generate', + 'status' => 'pending', + 'total_items' => 1, + 'processed_items' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'failures' => [], + ]); + + $this->mock(DriftFindingGenerator::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->once()->andReturn(0); + }); + + $job = new GenerateDriftFindingsJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + baselineRunId: (int) $baseline->getKey(), + currentRunId: (int) $current->getKey(), + scopeKey: $scopeKey, + bulkOperationRunId: (int) $run->getKey(), + ); + + $job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class)); + + expect($run->refresh()->status)->toBe('completed'); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => RunStatusChangedNotification::class, + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); +}); + +test('drift generation job sends failure notification with view link', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $scopeKey = hash('sha256', 'scope-job-notification-failure'); + + $baseline = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + $current = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'resource' => 'drift', + 'action' => 'generate', + 'status' => 'pending', + 'total_items' => 1, + 'processed_items' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'failures' => [], + ]); + + $this->mock(DriftFindingGenerator::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom')); + }); + + $job = new GenerateDriftFindingsJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + baselineRunId: (int) $baseline->getKey(), + currentRunId: (int) $current->getKey(), + scopeKey: $scopeKey, + bulkOperationRunId: (int) $run->getKey(), + ); + + try { + $job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class)); + } catch (RuntimeException) { + // Expected. + } + + $run->refresh(); + + expect($run->status)->toBe('failed') + ->and($run->processed_items)->toBe(1) + ->and($run->failed)->toBe(1) + ->and($run->failures)->toBeArray() + ->and($run->failures)->toHaveCount(1) + ->and($run->failures[0]['item_id'] ?? null)->toBe($scopeKey) + ->and($run->failures[0]['reason_code'] ?? null)->toBe('unknown') + ->and($run->failures[0]['reason'] ?? null)->toBe('boom'); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => RunStatusChangedNotification::class, + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); +}); diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php index 8553667..2190fb9 100644 --- a/tests/Feature/RunAuthorizationTenantIsolationTest.php +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -56,3 +56,31 @@ ->get(BulkOperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) ->assertForbidden(); }); + +test('readonly users can view bulk operation runs for their tenant', function () { + $tenant = Tenant::factory()->create(); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'resource' => 'drift', + 'action' => 'generate', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($user) + ->get(BulkOperationRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('drift') + ->assertSee('generate'); + + $this->actingAs($user) + ->get(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) + ->assertOk() + ->assertSee('drift') + ->assertSee('generate') + ->assertSee('Drift findings'); +}); diff --git a/tests/Unit/BulkOperationRunStatusBucketTest.php b/tests/Unit/BulkOperationRunStatusBucketTest.php new file mode 100644 index 0000000..4ee4e1d --- /dev/null +++ b/tests/Unit/BulkOperationRunStatusBucketTest.php @@ -0,0 +1,70 @@ +create([ + 'resource' => 'drift', + 'action' => 'generate', + ]); + + expect($run->runType())->toBe('drift.generate'); +}); + +test('bulk operation statusBucket maps pending and running', function () { + $pending = BulkOperationRun::factory()->create(['status' => 'pending']); + $running = BulkOperationRun::factory()->create(['status' => 'running']); + + expect($pending->statusBucket())->toBe('queued') + ->and($running->statusBucket())->toBe('running'); +}); + +test('bulk operation statusBucket maps terminal outcomes using counts', function () { + $succeeded = BulkOperationRun::factory()->create([ + 'status' => 'completed', + 'succeeded' => 3, + 'failed' => 0, + ]); + + $partial = BulkOperationRun::factory()->create([ + 'status' => 'completed_with_errors', + 'succeeded' => 2, + 'failed' => 1, + ]); + + $failedWithErrors = BulkOperationRun::factory()->create([ + 'status' => 'completed_with_errors', + 'succeeded' => 0, + 'failed' => 4, + ]); + + $failedAfterProgress = BulkOperationRun::factory()->create([ + 'status' => 'failed', + 'succeeded' => 1, + 'failed' => 1, + ]); + + $partialWithNonCountedFailures = BulkOperationRun::factory()->create([ + 'status' => 'completed_with_errors', + 'succeeded' => 1, + 'failed' => 0, + 'failures' => [ + [ + 'type' => 'foundation', + 'item_id' => 'foundation', + 'reason' => 'Forbidden', + 'reason_code' => 'graph_forbidden', + 'timestamp' => now()->toIso8601String(), + ], + ], + ]); + + expect($succeeded->statusBucket())->toBe('succeeded') + ->and($partial->statusBucket())->toBe('partially succeeded') + ->and($failedWithErrors->statusBucket())->toBe('failed') + ->and($failedAfterProgress->statusBucket())->toBe('partially succeeded') + ->and($partialWithNonCountedFailures->statusBucket())->toBe('partially succeeded'); +});