$overallStatus, 'permissions' => $permissions, 'last_refreshed_at' => now()->toIso8601String(), ]; } // (1) Successful run creates OperationRun with correct type and outcome it('creates OperationRun with correct type and outcome on success', function (): void { [$user, $tenant] = createUserWithTenant(); $comparison = buildJobComparison([ ['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']], ]); $job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison); $job->handle( app(FindingGeneratorContract::class), app(\App\Services\OperationRunService::class), ); $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK) ->first(); expect($run)->not->toBeNull() ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value); }); // (2) Skips tenant without provider connection it('skips tenant without provider connection', function (): void { $tenant = Tenant::factory()->create(); // Ensure workspace is set $workspace = \App\Models\Workspace::factory()->create(); $tenant->forceFill(['workspace_id' => $workspace->getKey()])->save(); // Explicitly delete any provider connections $tenant->providerConnections()->delete(); $comparison = buildJobComparison([ ['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']], ]); $job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison); $job->handle( app(FindingGeneratorContract::class), app(\App\Services\OperationRunService::class), ); expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0) ->and(StoredReport::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0) ->and(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK)->count())->toBe(0); }); // (3) Records summary counts on OperationRun it('records summary counts on OperationRun', function (): void { [$user, $tenant] = createUserWithTenant(); $comparison = buildJobComparison([ ['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']], ['key' => 'Perm.B', 'type' => 'application', 'status' => 'granted', 'features' => ['b']], ]); $job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison); $job->handle( app(FindingGeneratorContract::class), app(\App\Services\OperationRunService::class), ); $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK) ->first(); $counts = is_array($run->summary_counts) ? $run->summary_counts : []; expect($counts)->toHaveKey('findings_created') ->and($counts['findings_created'])->toBe(1) ->and($counts)->toHaveKey('posture_score') ->and($counts['posture_score'])->toBe(50); }); // (4) Handles generator exceptions gracefully it('marks OperationRun as failed on exception', function (): void { [$user, $tenant] = createUserWithTenant(); $this->mock(FindingGeneratorContract::class, function (MockInterface $mock): void { $mock->shouldReceive('generate')->andThrow(new RuntimeException('Test error')); }); $comparison = buildJobComparison([ ['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']], ]); $job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison); try { $job->handle( app(FindingGeneratorContract::class), app(\App\Services\OperationRunService::class), ); } catch (RuntimeException) { // Expected } $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK) ->first(); expect($run->outcome)->toBe(OperationRunOutcome::Failed->value); }); // (5) Dispatched from ProviderConnectionHealthCheckJob after successful compare it('dispatches posture job from health check job', function (): void { Queue::fake([GeneratePermissionPostureFindingsJob::class]); [$user, $tenant] = createUserWithTenant(); // The actual dispatch is tested by verifying the hook exists in the source // (integration test will cover the full flow in T033) Queue::assertNothingPushed(); });