set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1); $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(), ]); $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' => [ 'target_scope' => [ 'entra_tenant_id' => 'entra-1', ], '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, operationRun: $opRun, ); $job->handle( app(DriftFindingGenerator::class), app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class), ); $opRun->refresh(); expect($opRun->status)->toBe('completed'); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), 'type' => OperationRunCompleted::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'); config()->set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1); $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(), ]); $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' => [ 'target_scope' => [ 'entra_tenant_id' => 'entra-1', ], '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, operationRun: $opRun, ); try { $job->handle( app(DriftFindingGenerator::class), app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class), ); } catch (\RuntimeException) { // Expected. } $opRun->refresh(); expect($opRun->status)->toBe('completed') ->and($opRun->outcome)->toBe('failed'); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), 'type' => OperationRunCompleted::class, ]); $notification = $user->notifications()->latest('id')->first(); expect($notification)->not->toBeNull(); expect($notification->data['actions'][0]['url'] ?? null) ->toBe(OperationRunLinks::view($opRun, $tenant)); });