shouldReceive('build')->andReturn([ 'overview' => array_replace_recursive([ 'overall' => 'ready', 'counts' => [ 'missing_application' => 0, 'missing_delegated' => 0, ], 'freshness' => [ 'is_stale' => false, 'last_refreshed_at' => now()->toIso8601String(), ], ], $overview), ]); }); } /** * @return array{ * review:\App\Models\EnvironmentReview, * successor:\App\Models\EnvironmentReview|null, * reviewPack:ReviewPack, * } */ function spec352FeatureSeedBlockedReviewOutput(ManagedEnvironment $environment, User $user, bool $withSuccessorDraft = false): array { $snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0, operationRunCount: 0); $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); $review->forceFill([ 'status' => EnvironmentReviewStatus::Published->value, 'published_at' => now()->subHour(), 'published_by_user_id' => (int) $user->getKey(), 'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [ 'publish_blockers' => ['Operator approval note is still missing.'], ]), ])->save(); $reviewPack = ReviewPack::factory()->ready()->create([ 'managed_environment_id' => (int) $environment->getKey(), 'workspace_id' => (int) $environment->workspace_id, 'environment_review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), 'file_path' => 'review-packs/spec352-feature.zip', 'file_disk' => 'exports', 'generated_at' => now()->subMinutes(10), 'options' => [ 'include_pii' => false, 'include_operations' => true, ], ]); $review->forceFill([ 'current_export_review_pack_id' => (int) $reviewPack->getKey(), ])->save(); $successor = null; if ($withSuccessorDraft) { $successor = app(EnvironmentReviewLifecycleService::class)->createNextReview($review->fresh(), $user, $snapshot); } return [ 'review' => $review->fresh(), 'successor' => $successor?->fresh(), 'reviewPack' => $reviewPack->fresh(), ]; } /** * @return list */ function spec352DashboardHrefs(string $html, string $xpathExpression): array { $dom = new DOMDocument; libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); $xpath = new DOMXPath($dom); $nodes = $xpath->query($xpathExpression); if ($nodes === false) { return []; } return collect(iterator_to_array($nodes)) ->map(static fn (DOMNode $node): string => (string) $node->nodeValue) ->filter() ->values() ->all(); } function spec352DashboardNodeCount(string $html, string $xpathExpression): int { $dom = new DOMDocument; libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); $xpath = new DOMXPath($dom); $nodes = $xpath->query($xpathExpression); if ($nodes === false) { return 0; } return count(iterator_to_array($nodes)); } it('renders review output guidance as one dominant top case while preserving proof surfaces', function (): void { [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); mockSpec352FeatureDashboardPermissions(); spec352FeatureSeedBlockedReviewOutput($environment, $user, withSuccessorDraft: true); $this->actingAs($user); setAdminPanelContext($environment); $component = Livewire::test(EnvironmentDashboardOverview::class) ->assertSee('Draft review exists') ->assertSee('Open draft review') ->assertSee('Additional follow-ups') ->assertSee('Readiness dimensions') ->assertSee('Readiness proof') ->assertSee('Supporting signals') ->assertSee('Recommended next action'); $html = $component->html(); expect(substr_count($html, 'data-testid="tenant-dashboard-operator-guidance-title"'))->toBe(1) ->and(substr_count($html, 'data-testid="tenant-dashboard-primary-next-action"'))->toBe(1) ->and(substr_count($html, 'data-testid="tenant-dashboard-operator-guidance-secondary-actions"'))->toBe(1) ->and(substr_count($html, 'data-recommended-actions-style="compact"'))->toBe(1) ->and($html)->not->toContain('No single repo-real follow-up is currently available.') ->and(spec352DashboardNodeCount($html, "//*[@data-testid='tenant-dashboard-recommended-action']//*[contains(@class, 'fi-btn')]"))->toBe(0) ->and(substr_count($html, 'data-testid="tenant-dashboard-readiness-proof-panel"'))->toBe(1) ->and(substr_count($html, 'data-testid="tenant-dashboard-supporting-signals"'))->toBe(1); }); it('keeps dashboard guidance links scoped to the current environment and avoids mutation CTAs in the top block', function (): void { $environment = ManagedEnvironment::factory()->create(['name' => 'Spec352 Active Environment']); $otherEnvironment = ManagedEnvironment::factory()->create(['name' => 'Spec352 Other Environment']); [$user, $environment] = createUserWithTenant( tenant: $environment, role: 'owner', workspaceRole: 'manager', ); createUserWithTenant( tenant: $otherEnvironment, user: $user, role: 'owner', workspaceRole: 'manager', ); mockSpec352FeatureDashboardPermissions(); $activeState = spec352FeatureSeedBlockedReviewOutput($environment, $user, withSuccessorDraft: true); $otherState = spec352FeatureSeedBlockedReviewOutput($otherEnvironment, $user, withSuccessorDraft: true); $this->actingAs($user); setAdminPanelContext($environment); $component = Livewire::test(EnvironmentDashboardOverview::class) ->assertSee('Draft review exists') ->assertSee('Open draft review'); $html = $component->html(); $primaryHref = spec352DashboardHrefs( $html, "//*[@data-testid='tenant-dashboard-primary-next-action']//a/@href", ); $secondaryHrefs = spec352DashboardHrefs( $html, "//*[@data-testid='tenant-dashboard-operator-guidance-secondary-action']/@href", ); expect($primaryHref)->not->toBe([]) ->and(collect($primaryHref)->contains(EnvironmentReviewResource::environmentScopedUrl( 'view', ['record' => $activeState['successor']], $environment, )))->toBeTrue() ->and($secondaryHrefs)->not->toBe([]) ->and(collect($secondaryHrefs)->contains( fn (string $href): bool => str_contains($href, (string) $environment->getRouteKey()) || str_contains($href, (string) $environment->workspace->slug) ))->toBeTrue() ->and(collect($secondaryHrefs)->contains( fn (string $href): bool => str_contains($href, (string) $otherEnvironment->getRouteKey()) || str_contains($href, (string) $otherEnvironment->workspace->slug) || str_contains($href, '/environment-reviews/'.($otherState['successor']?->getKey())) ))->toBeFalse() ->and($html)->not->toContain('createNextReview') ->and($html)->not->toContain('publishReview') ->and($html)->not->toContain('refreshReviewInputs'); }); it('keeps only one clickable primary CTA on the page when review-output guidance is dominant', function (): void { [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); mockSpec352FeatureDashboardPermissions(); spec352FeatureSeedBlockedReviewOutput($environment, $user, withSuccessorDraft: true); $this->actingAs($user); setAdminEnvironmentContext($environment); $content = $this->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment)) ->assertSuccessful() ->assertSee('Draft review exists') ->assertSee('Open draft review') ->assertDontSee('No single repo-real follow-up is currently available.') ->getContent(); expect(spec352DashboardNodeCount( $content, "//a[contains(normalize-space(.), 'Open draft review')]", ))->toBe(1); }); it('renders the no-urgent-action state without Graph or outbound HTTP during dashboard page render', function (): void { [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); mockSpec352FeatureDashboardPermissions(); bindFailHardGraphClient(); workspaceOverviewSeedQuietTenantTruth($environment); $backupSet = workspaceOverviewSeedHealthyBackup($environment); workspaceOverviewSeedRestoreHistory($environment, $backupSet, 'completed'); setAdminEnvironmentContext($environment); assertNoOutboundHttp(function () use ($user, $environment): void { $this->actingAs($user) ->withSession([ WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id, WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [ (string) $environment->workspace_id => (int) $environment->getKey(), ], ]) ->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment)) ->assertSuccessful() ->assertSeeText('No urgent operator action') ->assertSeeText('Review environment') ->assertSeeText('Readiness dimensions') ->assertSeeText('Readiness proof'); }); });