$enableIpv4, 'enable_ipv6' => $enableIpv6, ]; expect($publicNet)->toBe([ 'enable_ipv4' => true, 'enable_ipv6' => true, ]); }); it('validates public_net configuration with IPv4 only', function () { $enableIpv4 = true; $enableIpv6 = false; $publicNet = [ 'enable_ipv4' => $enableIpv4, 'enable_ipv6' => $enableIpv6, ]; expect($publicNet)->toBe([ 'enable_ipv4' => true, 'enable_ipv6' => false, ]); }); it('validates public_net configuration with IPv6 only', function () { $enableIpv4 = false; $enableIpv6 = true; $publicNet = [ 'enable_ipv4' => $enableIpv4, 'enable_ipv6' => $enableIpv6, ]; expect($publicNet)->toBe([ 'enable_ipv4' => false, 'enable_ipv6' => true, ]); }); it('validates IP address selection prefers IPv4 when both are enabled', function () { $enableIpv4 = true; $enableIpv6 = true; $hetznerServer = [ 'public_net' => [ 'ipv4' => ['ip' => '1.2.3.4'], 'ipv6' => ['ip' => '2001:db8::1'], ], ]; $ipAddress = null; if ($enableIpv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($enableIpv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; } expect($ipAddress)->toBe('1.2.3.4'); }); it('validates IP address selection uses IPv6 when only IPv6 is enabled', function () { $enableIpv4 = false; $enableIpv6 = true; $hetznerServer = [ 'public_net' => [ 'ipv4' => ['ip' => '1.2.3.4'], 'ipv6' => ['ip' => '2001:db8::1'], ], ]; $ipAddress = null; if ($enableIpv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($enableIpv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; } expect($ipAddress)->toBe('2001:db8::1'); }); it('validates SSH key array merging logic with Coolify key', function () { $coolifyKeyId = 123; $selectedHetznerKeys = []; $sshKeys = array_merge( [$coolifyKeyId], $selectedHetznerKeys ); $sshKeys = array_unique($sshKeys); $sshKeys = array_values($sshKeys); expect($sshKeys)->toBe([123]) ->and(count($sshKeys))->toBe(1); }); it('validates SSH key array merging with additional Hetzner keys', function () { $coolifyKeyId = 123; $selectedHetznerKeys = [456, 789]; $sshKeys = array_merge( [$coolifyKeyId], $selectedHetznerKeys ); $sshKeys = array_unique($sshKeys); $sshKeys = array_values($sshKeys); expect($sshKeys)->toBe([123, 456, 789]) ->and(count($sshKeys))->toBe(3); }); it('validates deduplication when Coolify key is also in selected keys', function () { $coolifyKeyId = 123; $selectedHetznerKeys = [123, 456, 789]; $sshKeys = array_merge( [$coolifyKeyId], $selectedHetznerKeys ); $sshKeys = array_unique($sshKeys); $sshKeys = array_values($sshKeys); expect($sshKeys)->toBe([123, 456, 789]) ->and(count($sshKeys))->toBe(3); }); describe('Boarding Flow Integration', function () { uses(RefreshDatabase::class); beforeEach(function () { // Create a team with owner that has boarding enabled $this->team = Team::factory()->create([ 'show_boarding' => true, ]); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); // Set current team and act as user $this->actingAs($this->user); session(['currentTeam' => $this->team]); }); test('completes boarding when server is created from onboarding', function () { // Verify boarding is initially enabled expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component with from_onboarding flag $component = Livewire::test(ByHetzner::class) ->set('from_onboarding', true); // Verify the from_onboarding property is set expect($component->get('from_onboarding'))->toBeTrue(); // After successful server creation in the actual component, // the boarding should be marked as complete // Note: We can't fully test the createServer method without mocking Hetzner API // but we can verify the boarding completion logic is in place }); test('boarding flag remains unchanged when not from onboarding', function () { // Verify boarding is initially enabled expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component without from_onboarding flag (default false) Livewire::test(ByHetzner::class) ->set('from_onboarding', false); // Boarding should still be enabled since it wasn't created from onboarding expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); }); test('renders advanced Hetzner options in their own settings section', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) ->assertSee('Advanced options') ->assertSee('Provider SSH keys, networking, backups, and cloud-init.'); }); test('renders advanced Hetzner option controls in the advanced section', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) ->assertSee('Extra SSH keys') ->assertSee('Firewalls') ->assertSee('Private networks') ->assertSee('Enable Hetzner backups') ->assertSee('Add cloud-init script') ->assertSee('Adds 20% to the provider server price.'); }); test('lists account SSH keys and firewalls as listbox options and shows the selection', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) ->set('hetznerSshKeys', [['id' => 11, 'name' => 'deploy-key']]) ->set('hetznerFirewalls', [['id' => 38, 'name' => 'web-firewall'], ['id' => 39, 'name' => 'db-firewall']]) ->set('selectedHetznerFirewallIds', [38]) ->assertSee('deploy-key') ->assertSee('db-firewall') ->assertSeeHtml('x-text="current">web-firewall') ->assertSeeHtml('x-text="current">Select SSH keys'); }); test('shows the cloud init script name only when saving the script', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) ->set('show_cloud_init_script', true) ->assertSee('Cloud-init script') ->assertSee('Save this script for later') ->assertDontSee('Saved script name') ->set('save_cloud_init_script', true) ->assertSee('Saved script name'); }); }); describe('Hetzner data loading', function () { uses(RefreshDatabase::class); beforeEach(function () { $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->actingAs($this->user); session(['currentTeam' => $this->team]); $this->hetznerToken = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'test-hetzner-api-token', ]); $this->privateKey = PrivateKey::factory()->create([ 'team_id' => $this->team->id, ]); }); test('loads firewalls and networks for the selected Hetzner token', function () { Http::fake([ 'https://api.hetzner.cloud/v1/locations*' => Http::response([ 'locations' => [ ['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/images*' => Http::response([ 'images' => [ ['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ 'ssh_keys' => [], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ 'firewalls' => [ ['id' => 38, 'name' => 'web-firewall', 'rules' => []], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/networks*' => Http::response([ 'networks' => [ [ 'id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => [ ['type' => 'cloud', 'network_zone' => 'eu-central'], ], ], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), ]); $component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) ->assertSet('current_step', 2) ->call('loadHetznerData'); expect($component->get('hetznerFirewalls'))->toHaveCount(1) ->and($component->get('hetznerNetworks'))->toHaveCount(1); }); test('deletes the Hetzner server when saving it in Coolify fails', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response(['ssh_key' => ['id' => 123]], 201), 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ 'ssh_keys' => [], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/servers' => Http::response([ 'server' => ['id' => 456, 'public_net' => ['ipv4' => ['ip' => '203.0.113.10']]], ], 201), 'https://api.hetzner.cloud/v1/servers/456' => Http::response([], 200), ]); $eventDispatcher = Server::getEventDispatcher(); Server::setEventDispatcher(clone $eventDispatcher); Server::created(function (): void { throw new RuntimeException('local persistence failed'); }); try { Livewire::test(ByHetzner::class) ->set('current_step', 2) ->set('selected_token_id', $this->hetznerToken->id) ->set('server_name', 'test-server') ->set('selected_location', 'nbg1') ->set('selected_server_type', 'cx11') ->set('selected_image', 15512617) ->set('private_key_id', $this->privateKey->id) ->call('submit'); } finally { Server::setEventDispatcher($eventDispatcher); } $this->assertDatabaseMissing('servers', ['hetzner_server_id' => 456]); Http::assertSent(fn ($request) => $request->method() === 'DELETE' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456'); }); test('rejects submitting without a public IP protocol before calling Hetzner', function () { Http::fake(); Livewire::test(ByHetzner::class) ->set('current_step', 2) ->set('selected_token_id', $this->hetznerToken->id) ->set('server_name', 'test-server') ->set('selected_location', 'nbg1') ->set('selected_server_type', 'cx11') ->set('selected_image', 15512617) ->set('private_key_id', $this->privateKey->id) ->set('enable_ipv4', false) ->set('enable_ipv6', false) ->call('submit') ->assertHasErrors(['enable_ipv4', 'enable_ipv6']); Http::assertNothingSent(); }); test('lists server types that are out of stock in the selected location as out of stock', function () { Http::fake([ 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ ['id' => 1, 'name' => 'cx22', 'locations' => [ ['id' => 1, 'name' => 'nbg1', 'available' => false, 'deprecation' => null], ['id' => 2, 'name' => 'fsn1', 'available' => true, 'deprecation' => null], ]], ['id' => 2, 'name' => 'cx32', 'locations' => [ ['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null], ]], ['id' => 3, 'name' => 'cx11', 'locations' => [ ['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => ['announced' => '2025-09-24T00:00:00+00:00', 'unavailable_after' => '2025-12-31T00:00:00+00:00']], ]], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/*' => Http::response(['locations' => [], 'images' => [], 'ssh_keys' => [], 'firewalls' => [], 'networks' => [], 'meta' => ['pagination' => ['next_page' => null]]], 200), ]); $component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) ->call('loadHetznerData') ->set('selected_location', 'nbg1'); expect(collect($component->instance()->availableServerTypes)->mapWithKeys(fn ($type) => [$type['name'] => $type['out_of_stock']])->all()) ->toBe(['cx32' => false, 'cx22' => true]); }); test('does not create a server with a server type that is out of stock in the selected location', function () { Http::fake([ 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ ['id' => 1, 'name' => 'cx22', 'locations' => [ ['id' => 1, 'name' => 'nbg1', 'available' => false, 'deprecation' => null], ]], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), 'https://api.hetzner.cloud/v1/*' => Http::response(['locations' => [], 'images' => [], 'ssh_keys' => [], 'firewalls' => [], 'networks' => [], 'meta' => ['pagination' => ['next_page' => null]]], 200), ]); Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) ->call('loadHetznerData') ->set('server_name', 'test-server') ->set('selected_location', 'nbg1') ->set('selected_server_type', 'cx22') ->set('selected_image', 15512617) ->set('private_key_id', $this->privateKey->id) ->call('submit') ->assertHasErrors(['selected_server_type']); Http::assertNotSent(fn ($request) => $request->method() === 'POST'); }); });