feat(engine-b): customer domain models + migrations
customers, orders (ProvisioningSubject, stripe_event_id unique), instances (subdomain unique, encrypted nc_admin_ref), dns_records, backups, monitoring_targets, onboarding_tasks. Host capacity: committedGb/availableGb + datacenter placement. 7 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
b9742f61ee
commit
406251dfde
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Backup extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'external_job_id', 'schedule', 'last_ok_at', 'status'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['last_ok_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\CustomerFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = ['name', 'email', 'locale', 'stripe_customer_id', 'status'];
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function instances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DnsRecord extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'provider', 'record_id', 'fqdn', 'type', 'value'];
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Models\Concerns\HasUuid;
|
|||
use App\Provisioning\Contracts\ProvisioningSubject;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Host extends Model implements ProvisioningSubject
|
||||
|
|
@ -46,7 +47,12 @@ class Host extends Model implements ProvisioningSubject
|
|||
$this->update(['status' => 'error']);
|
||||
}
|
||||
|
||||
/** Free committable storage: total minus reserve. Instance quotas subtract in B. */
|
||||
public function instances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instance::class);
|
||||
}
|
||||
|
||||
/** Free committable storage: total minus reserve. */
|
||||
public function freeGb(): int
|
||||
{
|
||||
if ($this->total_gb === null) {
|
||||
|
|
@ -55,4 +61,30 @@ class Host extends Model implements ProvisioningSubject
|
|||
|
||||
return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
|
||||
}
|
||||
|
||||
/** Storage already committed to non-failed instances. */
|
||||
public function committedGb(): int
|
||||
{
|
||||
return (int) $this->instances()->where('status', '!=', 'failed')->sum('quota_gb');
|
||||
}
|
||||
|
||||
/** Storage still available for new instances. */
|
||||
public function availableGb(): int
|
||||
{
|
||||
return max(0, $this->freeGb() - $this->committedGb());
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement (spec §1): first active host in the datacenter with enough free
|
||||
* committable storage. Cluster is ignored in v1.0.
|
||||
*/
|
||||
public static function placeableIn(string $datacenter, int $quotaGb): ?self
|
||||
{
|
||||
return self::query()
|
||||
->where('datacenter', $datacenter)
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Instance extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InstanceFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id', 'order_id', 'host_id', 'vmid', 'plan', 'quota_gb', 'disk_gb',
|
||||
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
|
||||
'route_written', 'cert_ok', 'status',
|
||||
];
|
||||
|
||||
protected $hidden = ['nc_admin_ref'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nc_admin_ref' => 'encrypted',
|
||||
'route_written' => 'boolean',
|
||||
'cert_ok' => 'boolean',
|
||||
'vmid' => 'integer',
|
||||
'quota_gb' => 'integer',
|
||||
'disk_gb' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'cores' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function host(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Host::class);
|
||||
}
|
||||
|
||||
public function dnsRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(DnsRecord::class);
|
||||
}
|
||||
|
||||
public function backups(): HasMany
|
||||
{
|
||||
return $this->hasMany(Backup::class);
|
||||
}
|
||||
|
||||
public function monitoringTargets(): HasMany
|
||||
{
|
||||
return $this->hasMany(MonitoringTarget::class);
|
||||
}
|
||||
|
||||
public function onboardingTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(OnboardingTask::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MonitoringTarget extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'external_id', 'url', 'status'];
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OnboardingTask extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'key', 'done', 'done_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['done' => 'boolean', 'done_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function instance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Instance::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use App\Provisioning\Contracts\ProvisioningSubject;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Order extends Model implements ProvisioningSubject
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\OrderFactory> */
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id', 'plan', 'amount_cents', 'currency', 'datacenter',
|
||||
'stripe_event_id', 'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['amount_cents' => 'integer'];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function instance(): HasOne
|
||||
{
|
||||
return $this->hasOne(Instance::class);
|
||||
}
|
||||
|
||||
public function runs(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ProvisioningRun::class, 'subject');
|
||||
}
|
||||
|
||||
/** Runner hook: a failed provisioning run marks the order failed (no auto-refund). */
|
||||
public function onProvisioningFailed(): void
|
||||
{
|
||||
$this->update(['status' => 'failed']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<Customer> */
|
||||
class CustomerFactory extends Factory
|
||||
{
|
||||
protected $model = Customer::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company(),
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'locale' => 'de',
|
||||
'status' => 'active',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<Instance> */
|
||||
class InstanceFactory extends Factory
|
||||
{
|
||||
protected $model = Instance::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'customer_id' => Customer::factory(),
|
||||
'order_id' => Order::factory(),
|
||||
'plan' => 'start',
|
||||
'quota_gb' => 100,
|
||||
'disk_gb' => 120,
|
||||
'ram_mb' => 4096,
|
||||
'cores' => 2,
|
||||
'subdomain' => 'nc-'.$this->faker->unique()->numberBetween(1, 999999),
|
||||
'status' => 'reserving',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<Order> */
|
||||
class OrderFactory extends Factory
|
||||
{
|
||||
protected $model = Order::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'customer_id' => Customer::factory(),
|
||||
'plan' => 'start',
|
||||
'amount_cents' => 1900,
|
||||
'currency' => 'EUR',
|
||||
'datacenter' => 'fsn',
|
||||
'status' => 'paid',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('customers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
$table->string('locale', 5)->default('de');
|
||||
$table->string('stripe_customer_id')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('customers');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
|
||||
$table->string('plan'); // start|team|business|enterprise
|
||||
$table->unsignedInteger('amount_cents')->default(0);
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->string('datacenter'); // target region
|
||||
$table->string('stripe_event_id')->nullable()->unique(); // idempotency
|
||||
$table->string('status')->default('paid'); // paid|provisioning|active|failed
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('orders');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('instances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
|
||||
$table->foreignId('order_id')->constrained('orders')->cascadeOnDelete();
|
||||
$table->foreignId('host_id')->nullable()->constrained('hosts')->nullOnDelete();
|
||||
$table->unsignedInteger('vmid')->nullable();
|
||||
$table->string('plan');
|
||||
$table->unsignedBigInteger('quota_gb');
|
||||
$table->unsignedBigInteger('disk_gb');
|
||||
$table->unsignedInteger('ram_mb');
|
||||
$table->unsignedInteger('cores');
|
||||
$table->string('subdomain')->unique();
|
||||
$table->string('custom_domain')->nullable();
|
||||
$table->text('nc_admin_ref')->nullable(); // encrypted username/ref — never password
|
||||
$table->boolean('route_written')->default(false);
|
||||
$table->boolean('cert_ok')->default(false);
|
||||
$table->string('status')->default('reserving'); // reserving|provisioning|active|suspended|failed
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('instances');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dns_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
|
||||
$table->string('provider');
|
||||
$table->string('record_id');
|
||||
$table->string('fqdn');
|
||||
$table->string('type', 10);
|
||||
$table->string('value');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dns_records');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backups', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
|
||||
$table->string('external_job_id');
|
||||
$table->string('schedule');
|
||||
$table->timestamp('last_ok_at')->nullable();
|
||||
$table->string('status')->default('scheduled');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('backups');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('monitoring_targets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
|
||||
$table->string('external_id');
|
||||
$table->string('url');
|
||||
$table->string('status')->default('up');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('monitoring_targets');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('onboarding_tasks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
|
||||
$table->string('key');
|
||||
$table->boolean('done')->default(false);
|
||||
$table->timestamp('done_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('onboarding_tasks');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProvisioningRun;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
it('resolves an Order as the polymorphic run subject', function () {
|
||||
$order = Order::factory()->create();
|
||||
$run = ProvisioningRun::factory()->create([
|
||||
'subject_type' => Order::class,
|
||||
'subject_id' => $order->id,
|
||||
'pipeline' => 'customer',
|
||||
]);
|
||||
|
||||
expect($run->fresh()->subject->is($order))->toBeTrue()
|
||||
->and($order->getRouteKeyName())->toBe('uuid');
|
||||
});
|
||||
|
||||
it('marks the order failed via the ProvisioningSubject hook', function () {
|
||||
$order = Order::factory()->create(['status' => 'provisioning']);
|
||||
|
||||
$order->onProvisioningFailed();
|
||||
|
||||
expect($order->fresh()->status)->toBe('failed');
|
||||
});
|
||||
|
||||
it('enforces a unique stripe_event_id (idempotency)', function () {
|
||||
Order::factory()->create(['stripe_event_id' => 'evt_123']);
|
||||
|
||||
Order::factory()->create(['stripe_event_id' => 'evt_123']);
|
||||
})->throws(QueryException::class);
|
||||
|
||||
it('enforces a unique subdomain', function () {
|
||||
Instance::factory()->create(['subdomain' => 'kanzlei-berger']);
|
||||
|
||||
Instance::factory()->create(['subdomain' => 'kanzlei-berger']);
|
||||
})->throws(QueryException::class);
|
||||
|
||||
it('encrypts nc_admin_ref at rest', function () {
|
||||
$instance = Instance::factory()->create(['nc_admin_ref' => 'admin']);
|
||||
|
||||
$raw = \DB::table('instances')->where('id', $instance->id)->value('nc_admin_ref');
|
||||
expect($raw)->not->toBe('admin')
|
||||
->and($instance->fresh()->nc_admin_ref)->toBe('admin');
|
||||
});
|
||||
|
||||
it('computes host capacity from committed instance quotas', function () {
|
||||
$host = Host::factory()->active()->create(['total_gb' => 1000, 'reserve_pct' => 15]);
|
||||
// freeGb = 850
|
||||
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 100, 'status' => 'active']);
|
||||
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 200, 'status' => 'provisioning']);
|
||||
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 500, 'status' => 'failed']); // excluded
|
||||
|
||||
expect($host->committedGb())->toBe(300)
|
||||
->and($host->availableGb())->toBe(550);
|
||||
});
|
||||
|
||||
it('places an instance on an active host in the datacenter with room', function () {
|
||||
$full = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-a', 'total_gb' => 100, 'reserve_pct' => 15]);
|
||||
Instance::factory()->create(['host_id' => $full->id, 'quota_gb' => 80, 'status' => 'active']);
|
||||
$room = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-b', 'total_gb' => 1000, 'reserve_pct' => 15]);
|
||||
Host::factory()->active()->create(['datacenter' => 'hel', 'name' => 'pve-hel-a', 'total_gb' => 1000]);
|
||||
|
||||
$picked = Host::placeableIn('fsn', 100);
|
||||
|
||||
expect($picked?->is($room))->toBeTrue()
|
||||
->and(Host::placeableIn('nowhere', 100))->toBeNull();
|
||||
});
|
||||
Loading…
Reference in New Issue