create network if doesn't already exist on server, wip test
This commit is contained in:
@@ -11,5 +11,6 @@ class CreatedServer
|
|||||||
public string $status,
|
public string $status,
|
||||||
public string $ipv4,
|
public string $ipv4,
|
||||||
public string $ipv6,
|
public string $ipv6,
|
||||||
|
public string $networkId,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\GenerateRandomSlug;
|
use App\Actions\GenerateRandomSlug;
|
||||||
|
use App\Enums\NetworkType;
|
||||||
use App\Enums\ServerStatus;
|
use App\Enums\ServerStatus;
|
||||||
use App\Jobs\Servers\WaitForServerToConnect;
|
use App\Jobs\Servers\WaitForServerToConnect;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
@@ -57,7 +58,15 @@ class ServerController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
$request->validate([
|
||||||
|
'provider' => ['required', 'exists:providers,id'],
|
||||||
|
'server_type' => ['required', 'string'],
|
||||||
|
'location' => ['required', 'string'],
|
||||||
|
'image' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
$sudoPassword = Str::random(32);
|
$sudoPassword = Str::random(32);
|
||||||
|
/** @var Provider $provider */
|
||||||
$provider = Provider::findOrFail($request->provider);
|
$provider = Provider::findOrFail($request->provider);
|
||||||
$providerService = $provider->service();
|
$providerService = $provider->service();
|
||||||
|
|
||||||
@@ -65,11 +74,27 @@ class ServerController extends Controller
|
|||||||
return back()->with('error', 'Invalid provider');
|
return back()->with('error', 'Invalid provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $network = $provider->networks()->first()) {
|
||||||
|
// we need a keystone network to create this server
|
||||||
|
$createdNetwork = $providerService->createNetwork(
|
||||||
|
name: 'keystone',
|
||||||
|
);
|
||||||
|
|
||||||
|
$network = $provider->networks()->create([
|
||||||
|
'organisation_id' => $provider->organisation_id,
|
||||||
|
'external_id' => $createdNetwork->id,
|
||||||
|
'type' => NetworkType::EXTERNAL,
|
||||||
|
'name' => $createdNetwork->name,
|
||||||
|
'ip_range' => $createdNetwork->ipRange,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$createdServer = $providerService->createServer(
|
$createdServer = $providerService->createServer(
|
||||||
name: app(GenerateRandomSlug::class)->execute(), // @todo allow custom name
|
name: app(GenerateRandomSlug::class)->execute(), // @todo allow custom name
|
||||||
serverType: $request->server_type,
|
serverType: $request->server_type,
|
||||||
location: $request->location,
|
location: $request->location,
|
||||||
image: $request->image,
|
image: $request->image,
|
||||||
|
networkId: $network->external_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Integrations\Requests\Hetzner\Servers;
|
namespace App\Http\Integrations\Requests\Hetzner\Networks;
|
||||||
|
|
||||||
use Saloon\Contracts\Body\HasBody;
|
use Saloon\Contracts\Body\HasBody;
|
||||||
use Saloon\Enums\Method;
|
use Saloon\Enums\Method;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Integrations\Requests\Hetzner\Servers;
|
namespace App\Http\Integrations\Requests\Hetzner\Networks;
|
||||||
|
|
||||||
use Saloon\Contracts\Body\HasBody;
|
use Saloon\Contracts\Body\HasBody;
|
||||||
use Saloon\Enums\Method;
|
use Saloon\Enums\Method;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class CreateServerRequest extends Request implements HasBody
|
|||||||
protected ?string $name = null,
|
protected ?string $name = null,
|
||||||
protected ?string $serverType = null,
|
protected ?string $serverType = null,
|
||||||
protected ?string $location = null,
|
protected ?string $location = null,
|
||||||
|
protected ?array $networks = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
protected function defaultBody(): array
|
protected function defaultBody(): array
|
||||||
@@ -27,6 +28,7 @@ class CreateServerRequest extends Request implements HasBody
|
|||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'server_type' => $this->serverType,
|
'server_type' => $this->serverType,
|
||||||
'location' => $this->location,
|
'location' => $this->location,
|
||||||
|
'networks' => $this->networks,
|
||||||
'user_data' => file_get_contents(resource_path('scripts/hetzner-cloudinit.yml')),
|
'user_data' => file_get_contents(resource_path('scripts/hetzner-cloudinit.yml')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ class Organisation extends Model
|
|||||||
return $this->hasMany(Provider::class);
|
return $this->hasMany(Provider::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function networks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Network::class);
|
||||||
|
}
|
||||||
|
|
||||||
public static function createUniqueSlug(string $name): string
|
public static function createUniqueSlug(string $name): string
|
||||||
{
|
{
|
||||||
$slug = Str::slug($name);
|
$slug = Str::slug($name);
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ namespace App\Models;
|
|||||||
use App\Enums\ProviderType;
|
use App\Enums\ProviderType;
|
||||||
use App\Services\ServerProviders\HetznerService;
|
use App\Services\ServerProviders\HetznerService;
|
||||||
use App\Services\ServerProviders\ServerProviderService;
|
use App\Services\ServerProviders\ServerProviderService;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Provider extends Model
|
class Provider extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\ProviderFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -33,7 +37,7 @@ class Provider extends Model
|
|||||||
public function service(): ?ServerProviderService
|
public function service(): ?ServerProviderService
|
||||||
{
|
{
|
||||||
return match ($this->type) {
|
return match ($this->type) {
|
||||||
ProviderType::HETZNER => new HetznerService($this),
|
ProviderType::HETZNER => app(HetznerService::class, ['provider' => $this]),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use App\Data\ServerProviders\ServerType;
|
|||||||
use App\Http\Integrations\Connectors\HetznerConnector;
|
use App\Http\Integrations\Connectors\HetznerConnector;
|
||||||
use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest;
|
use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest;
|
||||||
use App\Http\Integrations\Requests\Hetzner\Locations\GetLocationsRequest;
|
use App\Http\Integrations\Requests\Hetzner\Locations\GetLocationsRequest;
|
||||||
|
use App\Http\Integrations\Requests\Hetzner\Networks\CreateNetworkRequest;
|
||||||
|
use App\Http\Integrations\Requests\Hetzner\Networks\GetNetworksRequest;
|
||||||
use App\Http\Integrations\Requests\Hetzner\Servers\CreateServerRequest;
|
use App\Http\Integrations\Requests\Hetzner\Servers\CreateServerRequest;
|
||||||
use App\Http\Integrations\Requests\Hetzner\Servers\GetNetworksRequest;
|
|
||||||
use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest;
|
use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest;
|
||||||
use App\Models\Provider;
|
use App\Models\Provider;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -29,12 +30,16 @@ class HetznerService extends ServerProviderService
|
|||||||
string $serverType,
|
string $serverType,
|
||||||
string $location,
|
string $location,
|
||||||
string $image,
|
string $image,
|
||||||
|
string $networkId,
|
||||||
): CreatedServer {
|
): CreatedServer {
|
||||||
$response = $this->connector->send(new CreateServerRequest(
|
$response = $this->connector->send(new CreateServerRequest(
|
||||||
image: $image,
|
image: $image,
|
||||||
name: $name,
|
name: $name,
|
||||||
serverType: $serverType,
|
serverType: $serverType,
|
||||||
location: $location,
|
location: $location,
|
||||||
|
networks: [
|
||||||
|
$networkId,
|
||||||
|
],
|
||||||
));
|
));
|
||||||
|
|
||||||
if ($response->status() !== 201) {
|
if ($response->status() !== 201) {
|
||||||
@@ -48,6 +53,7 @@ class HetznerService extends ServerProviderService
|
|||||||
status: $response->json('server.status'),
|
status: $response->json('server.status'),
|
||||||
ipv4: $response->json('server.public_net.ipv4.ip'),
|
ipv4: $response->json('server.public_net.ipv4.ip'),
|
||||||
ipv6: $response->json('server.public_net.ipv6.ip'),
|
ipv6: $response->json('server.public_net.ipv6.ip'),
|
||||||
|
networkId: $networkId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,4 +138,21 @@ class HetznerService extends ServerProviderService
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createNetwork(string $name): Network
|
||||||
|
{
|
||||||
|
$response = $this->connector->send(new CreateNetworkRequest(
|
||||||
|
name: $name,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($response->status() !== 201) {
|
||||||
|
throw new Exception('Failed to create network on Hetzner');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Network(
|
||||||
|
id: $response->json('network.id'),
|
||||||
|
name: $response->json('network.name'),
|
||||||
|
ipRange: $response->json('network.ip_range'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ abstract class ServerProviderService
|
|||||||
string $serverType,
|
string $serverType,
|
||||||
string $location,
|
string $location,
|
||||||
string $image,
|
string $image,
|
||||||
|
string $networkId,
|
||||||
): CreatedServer;
|
): CreatedServer;
|
||||||
|
|
||||||
abstract public function getServerTypes(): Collection;
|
abstract public function getServerTypes(): Collection;
|
||||||
@@ -25,4 +26,6 @@ abstract class ServerProviderService
|
|||||||
abstract public function getImages(): Collection;
|
abstract public function getImages(): Collection;
|
||||||
|
|
||||||
abstract public function findNetwork(string $name): ?Network;
|
abstract public function findNetwork(string $name): ?Network;
|
||||||
|
|
||||||
|
abstract public function createNetwork(string $name): Network;
|
||||||
}
|
}
|
||||||
|
|||||||
35
database/factories/ProviderFactory.php
Normal file
35
database/factories/ProviderFactory.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\ProviderType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Provider>
|
||||||
|
*/
|
||||||
|
class ProviderFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'keystone',
|
||||||
|
'type' => $this->faker->randomElement([
|
||||||
|
ProviderType::HETZNER,
|
||||||
|
]),
|
||||||
|
'token' => $this->faker->uuid(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOrganisation($organisationId): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'organisation_id' => $organisationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ class ServerFactory extends Factory
|
|||||||
'name' => $this->faker->word(),
|
'name' => $this->faker->word(),
|
||||||
'ipv4' => $this->faker->ipv4(),
|
'ipv4' => $this->faker->ipv4(),
|
||||||
'ipv6' => $this->faker->ipv6(),
|
'ipv6' => $this->faker->ipv6(),
|
||||||
|
'private_ip' => $this->faker->ipv4(),
|
||||||
'provider_status' => '',
|
'provider_status' => '',
|
||||||
'status' => $this->faker->randomElement(ServerStatus::toArray()),
|
'status' => $this->faker->randomElement(ServerStatus::toArray()),
|
||||||
'region' => '28',
|
'region' => '28',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ return new class extends Migration
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignIdFor(Organisation::class);
|
$table->foreignIdFor(Organisation::class);
|
||||||
$table->foreignIdFor(Network::class, 'external_network_id');
|
$table->foreignIdFor(Network::class, 'external_network_id');
|
||||||
$table->foreignIdFor(Network::class, 'internal_network_id');
|
$table->foreignIdFor(Network::class, 'internal_network_id')->nullable();
|
||||||
$table->foreignIdFor(Provider::class);
|
$table->foreignIdFor(Provider::class);
|
||||||
$table->string('external_id')->nullable();
|
$table->string('external_id')->nullable();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Data\ServerProviders\CreatedServer;
|
||||||
|
use App\Enums\NetworkType;
|
||||||
|
use App\Enums\ProviderType;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Provider;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ServerProviders\HetznerService;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Testing\AssertableInertia;
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
use function Pest\Laravel\actingAs;
|
use function Pest\Laravel\actingAs;
|
||||||
|
|
||||||
@@ -12,17 +19,31 @@ beforeEach(function () {
|
|||||||
// For example, using Laravel's RefreshDatabase trait
|
// For example, using Laravel's RefreshDatabase trait
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
actingAs($this->user);
|
actingAs($this->user);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('index route displays servers for an organisation', function () {
|
test('index route displays servers for an organisation', function () {
|
||||||
$organisation = Organisation::factory()->create();
|
$organisation = Organisation::factory()->create();
|
||||||
Server::factory()->count(2)->create(['organisation_id' => $organisation->id]);
|
$provider = Provider::factory()->forOrganisation($organisation->id)->create();
|
||||||
|
$network = $organisation->networks()->create([
|
||||||
|
'type' => NetworkType::EXTERNAL,
|
||||||
|
'name' => 'keystone',
|
||||||
|
'external_id' => 'net-12345',
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'ip_range' => fake()->ipv4() . '/24',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Server::factory()->count(2)->create([
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'organisation_id' => $organisation->id,
|
||||||
|
'external_network_id' => $network->id,
|
||||||
|
]);
|
||||||
|
|
||||||
$response = $this->get(route('servers.index', ['organisation' => $organisation->id]));
|
$response = $this->get(route('servers.index', ['organisation' => $organisation->id]));
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
$response->assertInertia(fn(AssertableInertia $page) => $page
|
||||||
->component('servers/Index'));
|
->component('servers/Index'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,7 +51,7 @@ test('create route returns inertia view', function () {
|
|||||||
$organisation = Organisation::factory()->create();
|
$organisation = Organisation::factory()->create();
|
||||||
$response = $this->get(route('servers.create', ['organisation' => $organisation->id]));
|
$response = $this->get(route('servers.create', ['organisation' => $organisation->id]));
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
$response->assertInertia(fn(AssertableInertia $page) => $page
|
||||||
->component('servers/Create'));
|
->component('servers/Create'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,15 +65,56 @@ test('store route fails with invalid provider', function () {
|
|||||||
'image' => 'ubuntu-20.04',
|
'image' => 'ubuntu-20.04',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertSessionHas('error', 'Invalid provider');
|
$response->assertSessionHasErrors(['provider' => 'The selected provider is invalid.']);
|
||||||
$response->assertStatus(302); // redirect back
|
$response->assertStatus(302); // redirect back
|
||||||
});
|
});
|
||||||
|
|
||||||
test('store route creates a server with valid data', function () {
|
test('store route creates a server with valid data', function () {
|
||||||
$organisation = Organisation::factory()->create();
|
$organisation = Organisation::factory()->create();
|
||||||
|
|
||||||
|
// Create a real provider first, then partially mock it
|
||||||
|
$provider = Provider::factory()->create([
|
||||||
|
'name' => 'hetzner',
|
||||||
|
'type' => ProviderType::HETZNER,
|
||||||
|
'token' => Str::uuid(),
|
||||||
|
'organisation_id' => $organisation->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$network = $organisation->networks()->create([
|
||||||
|
'type' => NetworkType::EXTERNAL,
|
||||||
|
'name' => 'keystone',
|
||||||
|
'external_id' => 'net-12345',
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'ip_range' => fake()->ipv4() . '/24',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(HetznerService::class, function (MockInterface $mock) use ($network) {
|
||||||
|
$mock->shouldReceive('createServer')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
Mockery::on(function ($arg) {
|
||||||
|
return is_string($arg['name']) && strlen($arg['name']) > 0;
|
||||||
|
}),
|
||||||
|
'cx11',
|
||||||
|
'hel1',
|
||||||
|
'ubuntu-20.04',
|
||||||
|
$network->external_id
|
||||||
|
)
|
||||||
|
->andReturn(new CreatedServer(
|
||||||
|
name: 'test-server-from-mock',
|
||||||
|
rootPassword: 'password123',
|
||||||
|
id: 'srv-12345',
|
||||||
|
status: 'running',
|
||||||
|
ipv4: '192.0.2.100',
|
||||||
|
ipv6: '2001:db8::100',
|
||||||
|
networkId: $network->external_id,
|
||||||
|
));
|
||||||
|
|
||||||
|
$mock->shouldReceive('createNetwork')->never();
|
||||||
|
});
|
||||||
|
|
||||||
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
|
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
|
||||||
'provider' => 'hetzner',
|
'provider' => $provider->id,
|
||||||
'server_type' => 'cx11',
|
'server_type' => 'cx11',
|
||||||
'location' => 'hel1',
|
'location' => 'hel1',
|
||||||
'image' => 'ubuntu-20.04',
|
'image' => 'ubuntu-20.04',
|
||||||
@@ -61,15 +123,24 @@ test('store route creates a server with valid data', function () {
|
|||||||
$response->assertRedirectContains('/servers/');
|
$response->assertRedirectContains('/servers/');
|
||||||
$this->assertDatabaseHas('servers', [
|
$this->assertDatabaseHas('servers', [
|
||||||
'organisation_id' => $organisation->id,
|
'organisation_id' => $organisation->id,
|
||||||
'provider' => 'hetzner',
|
'provider_id' => $provider->id,
|
||||||
'region' => 'hel1',
|
'region' => 'hel1',
|
||||||
'os' => 'ubuntu-20.04',
|
'os' => 'ubuntu-20.04',
|
||||||
|
'external_network_id' => $network->id,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('show route displays a single server', function () {
|
test('show route displays a single server', function () {
|
||||||
$organisation = Organisation::factory()->create();
|
$organisation = Organisation::factory()->create();
|
||||||
$server = Server::factory()->create(['organisation_id' => $organisation->id]);
|
$network = $organisation->networks()->create([
|
||||||
|
'type' => NetworkType::EXTERNAL,
|
||||||
|
'name' => 'keystone',
|
||||||
|
'external_id' => 'net-12345',
|
||||||
|
]);
|
||||||
|
$server = Server::factory()->create([
|
||||||
|
'organisation_id' => $organisation->id,
|
||||||
|
'external_network_id' => $network->id,
|
||||||
|
]);
|
||||||
|
|
||||||
$response = $this->get(route('servers.show', [
|
$response = $this->get(route('servers.show', [
|
||||||
'organisation' => $organisation->id,
|
'organisation' => $organisation->id,
|
||||||
@@ -77,6 +148,6 @@ test('show route displays a single server', function () {
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
$response->assertInertia(fn(AssertableInertia $page) => $page
|
||||||
->component('servers/Show'));
|
->component('servers/Show'));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user