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' => [], ]); $opRun = OperationRun::create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, 'type' => 'drift.generate', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => 'drift-hash-1', 'context' => [ 'scope_key' => $scopeKey, 'baseline_run_id' => (int) $baseline->getKey(), 'current_run_id' => (int) $current->getKey(), ], ]); $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(), operationRun: $opRun, ); $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' => DatabaseNotification::class, ]); $notification = $user->notifications()->latest('id')->first(); expect($notification)->not->toBeNull(); expect($notification->data['actions'][0]['url'] ?? null) ->toBe(OperationRunLinks::view($opRun, $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' => [], ]); $opRun = OperationRun::create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, 'type' => 'drift.generate', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => 'drift-hash-2', 'context' => [ 'scope_key' => $scopeKey, 'baseline_run_id' => (int) $baseline->getKey(), 'current_run_id' => (int) $current->getKey(), ], ]); $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(), operationRun: $opRun, ); 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' => DatabaseNotification::class, ]); $notification = $user->notifications()->latest('id')->first(); expect($notification)->not->toBeNull(); expect($notification->data['actions'][0]['url'] ?? null) ->toBe(OperationRunLinks::view($opRun, $tenant)); });