toBeTrue() ->and(Route::has('v5.home'))->toBeFalse() ->and(Route::has('v5.selection.update'))->toBeTrue() ->and(Route::has('v5.clusters.index'))->toBeTrue() ->and(Route::has('v5.clusters.store'))->toBeTrue() ->and(Route::has('v5.coolify.version'))->toBeFalse() ->and(Route::has('v5.coolify.bootstrap'))->toBeFalse(); }); it('uses separated v5 middleware groups', function () { $kernel = app(Kernel::class); $reflection = new ReflectionClass($kernel); $property = $reflection->getProperty('middlewareGroups'); $property->setAccessible(true); $groups = $property->getValue($kernel); expect($groups)->toHaveKey('v5.web') ->and($groups)->toHaveKey('v5.authenticated') ->and($groups['v5.web'])->toContain(HandleInertiaRequests::class) ->and($groups['v5.web'])->not->toContain(CheckForcePasswordReset::class) ->and($groups['v5.web'])->not->toContain(DecideWhatToDoWithUser::class) ->and($groups['v5.authenticated'])->toContain('auth') ->and($groups['v5.authenticated'])->toContain('verified') ->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class); }); it('reuses existing projects instead of creating v5 projects', function () { expect(file_exists(database_path('migrations/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse() ->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse(); }); it('creates v5 cluster tables and lets each server belong to one cluster', function () { createSharedUserAndTeamTables(); Schema::dropIfExists('v5_servers'); Schema::dropIfExists('v5_clusters'); $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); $clusterMigration->up(); expect(Schema::hasTable('v5_clusters'))->toBeTrue() ->and(Schema::hasColumns('v5_clusters', [ 'id', 'team_id', 'created_by_user_id', 'name', 'description', 'created_at', 'updated_at', ]))->toBeTrue(); $serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); $serverMigration->up(); expect(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue(); }); it('creates v5 server tables in the shared database', function () { createSharedUserAndTeamTables(); Schema::dropIfExists('v5_servers'); Schema::dropIfExists('v5_clusters'); $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); $clusterMigration->up(); $migration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); $migration->up(); expect(Schema::hasTable('v5_servers'))->toBeTrue() ->and(Schema::hasColumns('v5_servers', [ 'id', 'team_id', 'cluster_id', 'created_by_user_id', 'private_key_id', 'name', 'host', 'ssh_user', 'ssh_port', 'status', 'capabilities', 'builder_enabled', 'builder_capacity', 'last_bootstrapped_at', 'created_at', 'updated_at', ]))->toBeTrue(); }); it('includes v5 tables in the dev testing schema', function () { $schema = file_get_contents(database_path('schema/testing-schema.sql')); expect($schema)->toContain('"team_id" INTEGER NOT NULL') ->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL') ->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"') ->and($schema)->not->toContain('2026_06_04_050157_v5_create_projects_table') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"') ->and($schema)->toContain('"cluster_id" INTEGER') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"') ->and($schema)->toContain('"private_key_id" INTEGER') ->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table') ->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table') ->and($schema)->not->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table') ->and($schema)->not->toContain('2026_06_16_132000_make_v5_server_private_key_nullable') ->and($schema)->not->toContain('v5_hosts'); }); it('redirects guests to the shared login', function () { $this->get('/v5') ->assertRedirect('/login'); }); it('serves the v5 inertia shell', function () { app()->detectEnvironment(fn () => 'local'); $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); $user = User::withoutEvents(fn () => User::query()->create([ 'name' => 'Ada Lovelace', 'email' => 'ada@example.com', 'email_verified_at' => now(), 'password' => 'password', ])); $team = Team::withoutEvents(fn () => Team::query()->create([ 'name' => 'V5 Shared Team', 'description' => 'Shared team details', 'personal_team' => true, 'show_boarding' => false, ])); $user->teams()->attach($team, ['role' => 'owner']); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5') ->assertSuccessful() ->assertSee('', false) ->assertSee('coolify-logo-dev-transparent.png', false) ->assertDontSee('coolify-logo.svg', false) ->assertSee('v5-app', false) ->assertSee('Dashboard', false) ->assertDontSee('Home', false) ->assertDontSee('v5-ready', false) ->assertDontSee('This page is served from Laravel through Inertia and React') ->assertDontSee('Bootstrap server') ->assertDontSee('privateKeys', false) ->assertSee('Running') ->assertSee('Flux is running.') ->assertDontSee('"clusters":', false) ->assertDontSee('cooldServers', false) ->assertDontSee('coold-dev') ->assertDontSee('Create cluster') ->assertDontSee('Cluster details') ->assertDontSee('100.64.0.1') ->assertDontSee('Current team') ->assertDontSee('Your teams') ->assertDontSee('currentTeam', false) ->assertDontSee('teams', false) ->assertDontSee('V5 Shared Team') ->assertDontSee('Shared team details'); }); it('serves the production v5 favicon outside local environments', function () { app()->detectEnvironment(fn () => 'production'); $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5') ->assertSuccessful() ->assertSee('coolify-logo.svg', false) ->assertDontSee('coolify-logo-dev-transparent.png', false); }); it('shows v5 clusters with their servers on the cluster page', function () { $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $privateKey = createV5PrivateKey($team, 'Lima Key'); $cluster = Cluster::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'Development-Lima', 'description' => 'Local Lima development cluster managed by scripts/dev.sh.', ]); V5Server::query()->create([ 'team_id' => $team->id, 'cluster_id' => $cluster->id, 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey->id, 'name' => 'coold-dev', 'host' => 'lima-coold-dev', 'ssh_user' => 'developer', 'ssh_port' => 22, 'status' => 'installed', 'capabilities' => ['coold', 'builder'], 'builder_enabled' => true, 'builder_capacity' => 2, 'last_bootstrapped_at' => now(), ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5/clusters') ->assertSuccessful() ->assertSee('Clusters', false) ->assertSee('"clusters":[', false) ->assertSee('"name":"Development-Lima"', false) ->assertSee('"serversCount":1', false) ->assertSee('"name":"coold-dev"', false) ->assertSee('"host":"lima-coold-dev"', false) ->assertSee('"sshUser":"developer"', false) ->assertSee('"sshPort":22', false) ->assertSee('"builderEnabled":true', false) ->assertSee('"builderCapacity":2', false) ->assertSee('"privateKeyName":"Lima Key"', false) ->assertSee('"lastBootstrappedAt":"', false); }); it('creates a v5 cluster for the current team', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/clusters', [ 'name' => 'Production Mesh', 'description' => 'Primary production cluster.', ]) ->assertCreated() ->assertJsonPath('cluster.name', 'Production Mesh') ->assertJsonPath('cluster.description', 'Primary production cluster.') ->assertJsonPath('cluster.serversCount', 0) ->assertJsonPath('cluster.servers', []); expect(Cluster::query() ->where('team_id', $team->id) ->where('created_by_user_id', $user->id) ->where('name', 'Production Mesh') ->where('description', 'Primary production cluster.') ->exists())->toBeTrue(); }); it('validates v5 cluster creation input', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/clusters', [ 'name' => '', 'description' => str_repeat('a', 1001), ]) ->assertUnprocessable() ->assertJsonValidationErrors(['name', 'description']); }); it('rejects duplicate v5 cluster names in the same team', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); Cluster::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, 'name' => 'Production Mesh', 'description' => null, ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/clusters', [ 'name' => 'Production Mesh', 'description' => null, ]) ->assertUnprocessable() ->assertJsonValidationErrors(['name']); }); it('allows the same v5 cluster name in another team without leaking it', function () { $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $otherTeam = Team::withoutEvents(fn () => Team::query()->create([ 'name' => 'Other V5 Team', 'description' => null, 'personal_team' => false, 'show_boarding' => false, ])); $otherUser = User::withoutEvents(fn () => User::query()->create([ 'name' => 'Other User', 'email' => 'other@example.com', 'email_verified_at' => now(), 'password' => 'password', ])); Cluster::query()->create([ 'team_id' => $otherTeam->id, 'created_by_user_id' => $otherUser->id, 'name' => 'Production Mesh', 'description' => 'Other team cluster.', ]); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/clusters', [ 'name' => 'Production Mesh', 'description' => 'Current team cluster.', ]) ->assertCreated() ->assertJsonPath('cluster.description', 'Current team cluster.'); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5/clusters') ->assertSuccessful() ->assertSee('Current team cluster.') ->assertDontSee('Other team cluster.'); }); it('shares existing projects and environments with the v5 dashboard page', function () { $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); [$project, $environment] = createV5ProjectWithEnvironment($team, 'gravy-truck', 'production'); createV5ProjectWithEnvironment($team, 'rocket-bike', 'staging'); $otherTeam = Team::withoutEvents(fn () => Team::query()->create([ 'name' => 'Other V5 Team', 'description' => null, 'personal_team' => false, 'show_boarding' => false, ])); [$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private'); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->get('/v5') ->assertSuccessful() ->assertSee('"projects":[', false) ->assertSee('"name":"gravy-truck"', false) ->assertSee('"uuid":"'.$project->uuid.'"', false) ->assertSee('"environments":[', false) ->assertSee('"name":"production"', false) ->assertSee('"uuid":"'.$environment->uuid.'"', false) ->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false) ->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false) ->assertDontSee('"id":"'.$project->id.'","uuid":"'.$project->uuid.'"', false) ->assertDontSee('"id":"'.$environment->id.'","uuid":"'.$environment->uuid.'"', false) ->assertDontSee($otherProject->name) ->assertDontSee($otherProject->uuid) ->assertDontSee($otherEnvironment->name) ->assertDontSee($otherEnvironment->uuid); }); it('persists the selected v5 project and environment in the session', function () { $this->withoutVite(); fakeFluxHealth(); createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); createV5ProjectWithEnvironment($team, 'alpha-project', 'production'); [$selectedProject, $selectedEnvironment] = createV5ProjectWithEnvironment($team, 'zebra-project', 'staging'); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/selection', [ 'project_uuid' => $selectedProject->uuid, 'environment_uuid' => $selectedEnvironment->uuid, ]) ->assertNoContent() ->assertSessionHas('v5.selectedProjectUuid', $selectedProject->uuid) ->assertSessionHas('v5.selectedEnvironmentUuid', $selectedEnvironment->uuid); $this ->actingAs($user) ->get('/v5') ->assertSuccessful() ->assertSee('"selectedProjectUuid":"'.$selectedProject->uuid.'"', false) ->assertSee('"selectedEnvironmentUuid":"'.$selectedEnvironment->uuid.'"', false); }); it('rejects persisted v5 selections outside the current team', function () { createSharedUserAndTeamTables(); [$user, $team] = createV5UserWithTeam(); $otherTeam = Team::withoutEvents(fn () => Team::query()->create([ 'name' => 'Other V5 Team', 'description' => null, 'personal_team' => false, 'show_boarding' => false, ])); [$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private'); $this ->actingAs($user) ->withSession(['currentTeam' => $team]) ->postJson('/v5/selection', [ 'project_uuid' => $otherProject->uuid, 'environment_uuid' => $otherEnvironment->uuid, ]) ->assertForbidden() ->assertSessionMissing('v5.selectedProjectUuid') ->assertSessionMissing('v5.selectedEnvironmentUuid'); }); it('defines the v5 dashboard page as a shadcn styled canvas shell', function () { $dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); $app = file_get_contents(resource_path('js/v5/app.tsx')); $navbarPath = resource_path('js/v5/components/app-navbar.tsx'); expect(file_exists($navbarPath))->toBeTrue(); $navbar = file_get_contents($navbarPath); $sheetPath = resource_path('js/v5/components/ui/sheet.tsx'); expect(file_exists($sheetPath))->toBeTrue(); expect($app) ->toContain('progress: {') ->toContain('delay: 10') ->toContain("color: '#fcd452'") ->toContain('showSpinner: false') ->not->toContain('TopNavigationLoadingIndicator') ->not->toContain('withApp:'); expect($dashboardPage) ->toContain('Dashboard') ->toContain("import { AppNavbar } from '@/components/app-navbar';") ->not->toContain('function csrfToken()') ->not->toContain("import { csrfToken } from '@/lib/csrf';") ->not->toContain("import { Button } from '@/components/ui/button';") ->not->toContain("fetch('/v5/clusters'") ->toContain('toContain('bg-background text-foreground') ->toContain('h-dvh overflow-hidden bg-background text-foreground') ->toContain('flex h-full min-h-0 items-center justify-center overflow-hidden px-6 pt-16') ->not->toContain('not->toContain('h-[calc(100dvh-4rem)]') ->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground') ->toContain('This is where the magic happens.') ->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';") ->not->toContain("fetch('/v5/selection'"); expect($navbar) ->toContain("import { Link, usePage } from '@inertiajs/react';") ->toContain("import { cn } from '@/lib/utils';") ->toContain("import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';") ->toContain("import { csrfToken } from '@/lib/csrf';") ->not->toContain('function csrfToken()') ->toContain('export function AppNavbar') ->toContain('/coolify-logo.svg') ->toContain('toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"') ->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"') ->toContain('bg-muted/40') ->toContain('text-muted-foreground') ->toContain('SelectGroup') ->toContain('variant="ghost"') ->toContain('position="popper"') ->toContain('sideOffset={4}') ->toContain('Select a project') ->toContain('Select an environment') ->toContain('const { url } = usePage();') ->toContain("const isClustersPage = url.startsWith('/v5/clusters');") ->toContain('href="/v5"') ->toContain('href="/v5/clusters"') ->toContain('Clusters') ->toContain('className="relative flex h-16 items-center gap-3 px-4 sm:px-6"') ->toContain('className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2"') ->toContain('className="max-w-[38vw] md:max-w-[10rem]"') ->toContain('className="max-w-[30vw] md:max-w-[10rem]"') ->toContain('aria-label="Open mobile menu"') ->toContain('') ->toContain('toContain('toContain('') ->toContain('Coolify') ->toContain('') ->toContain('toContain('Move between Coolify v5 pages.') ->not->toContain('Navigation') ->toContain('className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"') ->toContain('className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"') ->toContain('Dashboard') ->not->toContain('Home') ->toContain("fetch('/v5/selection'") ->toContain("'X-CSRF-TOKEN': csrfToken()") ->not->toContain('isMobileMenuOpen') ->not->toContain('setIsMobileMenuOpen') ->not->toContain('isProjectEnvironmentMenuOpen') ->not->toContain('setIsProjectEnvironmentMenuOpen') ->not->toContain('Open project and environment selector') ->not->toContain('Close project and environment selector') ->not->toContain('DropdownMenu') ->not->toContain('fixed inset-0 bg-black/80') ->not->toContain('fixed inset-y-0 right-0') ->not->toContain('not->toContain("import { Button } from '@/components/ui/button';") ->not->toContain('not->toContain("import { Separator } from '@/components/ui/separator';") ->not->toContain('not->toContain('min-h-[calc(100vh-4rem)]') ->not->toContain('py-10') ->not->toContain('bg-background/95') ->not->toContain('backdrop-blur') ->not->toContain('supports-[backdrop-filter]') ->not->toContain('border-coolgray') ->not->toContain('className="w-[12rem]"') ->not->toContain('

Coolify v5

') ->not->toContain('

Clusters

'); expect(file_exists(resource_path('js/v5/components/top-navigation-loading-indicator.tsx')))->toBeFalse(); }); it('defines the v5 cluster management page and create cluster form', function () { $clustersPagePath = resource_path('js/v5/Pages/Clusters.tsx'); $clustersPage = file_get_contents($clustersPagePath); $buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx')); $types = file_get_contents(resource_path('js/v5/types.ts')); expect(file_exists($clustersPagePath))->toBeTrue(); expect($clustersPage) ->toContain("import { Button } from '@/components/ui/button';") ->toContain("} from '@/components/ui/dialog';") ->toContain("import { csrfToken } from '@/lib/csrf';") ->toContain("fetch('/v5/clusters'") ->toContain('aria-label="Create cluster"') ->toContain('setIsCreateDialogOpen(true)') ->toContain('Add cluster') ->toContain('variant="coolify"') ->toContain('border-warning bg-warning/10 text-foreground') ->not->toContain('border-primary bg-primary/10 text-foreground') ->toContain('toContain('Create cluster') ->toContain('') ->toContain('') ->toContain('toContain('Create cluster') ->toContain('Cluster details') ->toContain('Servers in this cluster') ->toContain('selectedCluster') ->toContain('builderCapacity') ->toContain('privateKeyName') ->toContain('lastBootstrappedAt') ->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]') ->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]') ->not->toContain('New cluster') ->not->toContain('