feat: 053 unify runs monitoring
- Add statusBucket/runType semantics for BulkOperationRun\n- Add Monitoring/Operations hub UI (view-only) with filters + related links\n- Add Drift run context + lifecycle notifications and guardrails\n- Add Pest coverage for status buckets and drift notifications
This commit is contained in:
parent
d9554010ac
commit
0b0c2b70b9
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
143
tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php
Normal file
143
tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php
Normal 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));
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
70
tests/Unit/BulkOperationRunStatusBucketTest.php
Normal file
70
tests/Unit/BulkOperationRunStatusBucketTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user