create(); $owner = User::factory()->create(['name' => 'Decision Owner']); $approver = User::factory()->create(['name' => 'Decision Approver']); $visibleTenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'name' => 'Visible Tenant', 'external_id' => 'visible-tenant', ]); $hiddenTenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'name' => 'Hidden Tenant', 'external_id' => 'hidden-tenant', ]); $pendingApproval = makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $owner, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Pending workspace approval', exceptionAttributes: [ 'requested_at' => now()->subDays(2), 'review_due_at' => now()->addDay(), ], decisionAttributes: [ 'decided_at' => now()->subDays(2), ], ); $followUpNeeded = makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $approver, status: FindingException::STATUS_EXPIRING, validityState: FindingException::VALIDITY_EXPIRING, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Approved until remediation completes', exceptionAttributes: [ 'approved_by_user_id' => (int) $approver->getKey(), 'approved_at' => now()->subDays(5), 'effective_from' => now()->subDays(5), 'expires_at' => now()->addDays(2), 'review_due_at' => now()->addDay(), ], decisionAttributes: [ 'decided_at' => now()->subDays(5), ], ); $recentlyRejected = makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $approver, status: FindingException::STATUS_REJECTED, validityState: FindingException::VALIDITY_REJECTED, decisionType: FindingExceptionDecision::TYPE_REJECTED, decisionReason: 'Evidence bundle was incomplete', exceptionAttributes: [ 'approved_by_user_id' => (int) $approver->getKey(), 'rejected_at' => now()->subDays(3), 'review_due_at' => now()->subDays(4), ], decisionAttributes: [ 'decided_at' => now()->subDays(3), ], ); makeFindingExceptionWithCurrentDecision( tenant: $visibleTenant, owner: $owner, actor: $approver, status: FindingException::STATUS_REVOKED, validityState: FindingException::VALIDITY_REVOKED, decisionType: FindingExceptionDecision::TYPE_REVOKED, decisionReason: 'Closed long ago', exceptionAttributes: [ 'approved_by_user_id' => (int) $approver->getKey(), 'revoked_at' => now()->subDays(45), 'review_due_at' => now()->subDays(46), ], decisionAttributes: [ 'decided_at' => now()->subDays(45), ], ); makeFindingExceptionWithCurrentDecision( tenant: $hiddenTenant, owner: $owner, actor: $owner, status: FindingException::STATUS_PENDING, validityState: FindingException::VALIDITY_MISSING_SUPPORT, decisionType: FindingExceptionDecision::TYPE_REQUESTED, decisionReason: 'Hidden tenant request', exceptionAttributes: [ 'requested_at' => now()->subDay(), 'review_due_at' => now()->addDays(2), ], decisionAttributes: [ 'decided_at' => now()->subDay(), ], ); $builder = app(GovernanceDecisionRegisterBuilder::class); $openPayload = $builder->build( workspace: $workspace, visibleTenants: [$visibleTenant], registerState: 'open', ); $openRows = collect($openPayload['rows'])->keyBy('exception_id'); expect($openPayload['counts'])->toMatchArray([ 'open' => 2, 'recently_closed' => 1, ]) ->and($openRows->keys()->all())->toBe([ (int) $pendingApproval->getKey(), (int) $followUpNeeded->getKey(), ]) ->and($openRows[(int) $pendingApproval->getKey()]['tenant_name'])->toBe('Visible Tenant') ->and($openRows[(int) $pendingApproval->getKey()]['owner_name'])->toBe('Decision Owner') ->and($openRows[(int) $pendingApproval->getKey()]['next_action_label'])->toBe('Review approval') ->and($openRows[(int) $followUpNeeded->getKey()]['next_action_label'])->toBe('Review follow-up'); $recentlyClosedPayload = $builder->build( workspace: $workspace, visibleTenants: [$visibleTenant], registerState: 'recently_closed', ); expect($recentlyClosedPayload['counts'])->toMatchArray([ 'open' => 2, 'recently_closed' => 1, ]) ->and(collect($recentlyClosedPayload['rows'])->pluck('exception_id')->all())->toBe([ (int) $recentlyRejected->getKey(), ]) ->and($recentlyClosedPayload['rows'][0]['closure_reason'])->toBe('Evidence bundle was incomplete') ->and($recentlyClosedPayload['rows'][0]['status'])->toBe(FindingException::STATUS_REJECTED); }); it('keeps missing owner visible instead of omitting follow-up-needed rows', function (): void { $workspace = Workspace::factory()->create(); $requester = User::factory()->create(); $tenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), ]); $unownedException = makeFindingExceptionWithCurrentDecision( tenant: $tenant, owner: null, actor: $requester, status: FindingException::STATUS_EXPIRED, validityState: FindingException::VALIDITY_EXPIRED, decisionType: FindingExceptionDecision::TYPE_APPROVED, decisionReason: 'Expired and needs a fresh decision', exceptionAttributes: [ 'requested_by_user_id' => (int) $requester->getKey(), 'owner_user_id' => null, 'approved_by_user_id' => (int) $requester->getKey(), 'approved_at' => now()->subDays(20), 'effective_from' => now()->subDays(20), 'expires_at' => now()->subDay(), 'review_due_at' => now()->subDays(2), ], decisionAttributes: [ 'decided_at' => now()->subDays(20), ], ); $payload = app(GovernanceDecisionRegisterBuilder::class)->build( workspace: $workspace, visibleTenants: [$tenant], registerState: 'open', ); expect($payload['rows'])->toHaveCount(1) ->and($payload['rows'][0]['exception_id'])->toBe((int) $unownedException->getKey()) ->and($payload['rows'][0]['owner_name'])->toBeNull() ->and($payload['rows'][0]['next_action_label'])->toBe('Review follow-up'); }); /** * @param array $exceptionAttributes * @param array $decisionAttributes */ function makeFindingExceptionWithCurrentDecision( Tenant $tenant, ?User $owner, User $actor, string $status, string $validityState, string $decisionType, string $decisionReason, array $exceptionAttributes = [], array $decisionAttributes = [], ): FindingException { $requesterId = $exceptionAttributes['requested_by_user_id'] ?? (int) $actor->getKey(); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); $exception = FindingException::query()->create(array_merge([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'finding_id' => (int) $finding->getKey(), 'requested_by_user_id' => $requesterId, 'owner_user_id' => $owner?->getKey(), 'status' => $status, 'current_validity_state' => $validityState, 'request_reason' => 'Decision register test setup', 'requested_at' => now()->subDays(7), 'review_due_at' => now()->addDays(7), 'evidence_summary' => ['reference_count' => 0], ], $exceptionAttributes)); $decision = $exception->decisions()->create(array_merge([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => $decisionType, 'reason' => $decisionReason, 'metadata' => [], 'decided_at' => now()->subDays(7), ], $decisionAttributes)); $exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save(); return $exception->fresh(['tenant', 'owner', 'currentDecision']); }