feat/053-unify-runs-monitoring #60

Merged
ahmido merged 2 commits from feat/053-unify-runs-monitoring into dev 2026-01-16 15:10:31 +00:00
9 changed files with 687 additions and 10 deletions
Showing only changes of commit 0b0c2b70b9 - Show all commits

View File

@ -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

View File

@ -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<string, string>
*/
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;
}
}

View File

@ -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(),
]);
}
}
}

View File

@ -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',
};
}
}

View File

@ -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',
};

View File

@ -1,9 +1,11 @@
<?php
use App\Filament\Pages\DriftLanding;
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Notifications\RunStatusChangedNotification;
use App\Support\RunIdempotency;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
@ -53,6 +55,22 @@
expect($bulkRun->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);
});

View File

@ -0,0 +1,143 @@
<?php
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftFindingGenerator;
use Mockery\MockInterface;
test('drift generation job sends completion notification with view link', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$scopeKey = hash('sha256', 'scope-job-notification-success');
$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()->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));
});

View File

@ -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');
});

View File

@ -0,0 +1,70 @@
<?php
use App\Models\BulkOperationRun;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bulk operation runType returns resource.action', function () {
$run = BulkOperationRun::factory()->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');
});