From 4ad1828f276f392da68b0b3384a76081bc2184bc Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 16:06:14 +0200 Subject: [PATCH] Ein reservierter Host gehoert seinem Kunden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit — der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache. hosts.reserved_for_customer_id (nullable, ueberlebt den Kunden) markiert die Maschine; Host::placeableIn() und HostCapacity zaehlen sie nur noch fuer den eigenen Mieter oder gar nicht mehr zum allgemeinen Bestand. Auf der Host-Detailseite kann ein Operator reservieren und wieder loesen — Loesen laeuft ueber ein eigenes Bestaetigungsmodal (R23), das selbst nichts aendert, sondern nur an HostDetail::releaseReservation() zurueckmeldet. Host-Liste und Kapazitaetsseite weisen eine reservierte Maschine als solche aus, statt sie kommentarlos verschwinden zu lassen. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Capacity.php | 5 +- .../Admin/ConfirmReleaseReservation.php | 50 ++++++ app/Livewire/Admin/HostDetail.php | 45 ++++++ app/Livewire/Admin/Hosts.php | 2 +- app/Models/Host.php | 31 +++- .../Steps/Customer/ReserveResources.php | 2 +- app/Services/Provisioning/HostCapacity.php | 4 + ..._08_01_000003_add_reservation_to_hosts.php | 36 +++++ lang/de/hosts.php | 17 ++ lang/en/hosts.php | 17 ++ .../views/livewire/admin/capacity.blade.php | 11 +- .../confirm-release-reservation.blade.php | 17 ++ .../livewire/admin/host-detail.blade.php | 28 ++++ .../views/livewire/admin/hosts.blade.php | 7 + tests/Feature/Admin/HostReservationTest.php | 147 ++++++++++++++++++ 15 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmReleaseReservation.php create mode 100644 database/migrations/2026_08_01_000003_add_reservation_to_hosts.php create mode 100644 resources/views/livewire/admin/confirm-release-reservation.blade.php create mode 100644 tests/Feature/Admin/HostReservationTest.php diff --git a/app/Livewire/Admin/Capacity.php b/app/Livewire/Admin/Capacity.php index b931668..3403daa 100644 --- a/app/Livewire/Admin/Capacity.php +++ b/app/Livewire/Admin/Capacity.php @@ -101,7 +101,10 @@ class Capacity extends Component 'parked' => $capacity->parked(), 'demand' => $demand, 'unplaceable' => $capacity->unplaceablePlans(), - 'hosts' => Host::query()->where('status', 'active')->orderBy('name')->get(), + // reservedFor eager-loaded: die Tafel "was die Hosts noch + // aufnehmen" weist eine reservierte Maschine als solche aus, + // statt sie kommentarlos neben dem allgemeinen Bestand zu zeigen. + 'hosts' => Host::query()->where('status', 'active')->orderBy('name')->with('reservedFor')->get(), // Only when something is actually waiting. A shopping list on an // idle estate is an invitation to spend money for no reason. 'offers' => $demand['count'] > 0 diff --git a/app/Livewire/Admin/ConfirmReleaseReservation.php b/app/Livewire/Admin/ConfirmReleaseReservation.php new file mode 100644 index 0000000..88f0dd0 --- /dev/null +++ b/app/Livewire/Admin/ConfirmReleaseReservation.php @@ -0,0 +1,50 @@ +authorize('hosts.manage'); + + $host = Host::query()->where('uuid', $uuid)->with('reservedFor')->first(); + + $this->uuid = $uuid; + $this->hostName = $host?->name ?? ''; + $this->customerName = $host?->reservedFor?->name ?? ''; + } + + public function confirmRelease(): void + { + $this->authorize('hosts.manage'); + $this->dispatch('host-reservation-release-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-release-reservation'); + } +} diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 9d5ba73..a8fc908 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Models\Customer; use App\Models\Host; use App\Models\ProvisioningRun; use App\Provisioning\Jobs\AdvanceRunJob; @@ -67,6 +68,46 @@ class HostDetail extends Component } } + /** + * "Eigener Server" verkauft, ab jetzt auch Tatsache: der Host gehört + * diesem Kunden, und Host::placeableIn() hält ab sofort alle anderen + * fern. Braucht keine Bestätigung — niemandem wird etwas weggenommen. + */ + public function reserveFor(string $customerUuid): void + { + $this->authorize('hosts.manage'); + + $customer = Customer::query()->where('uuid', $customerUuid)->first(); + + if ($customer === null) { + return; + } + + $this->host->update(['reserved_for_customer_id' => $customer->id]); + $this->dispatch('notify', message: __('hosts.detail.reserved_saved', ['name' => $customer->name])); + } + + /** + * Gibt die Maschine an den allgemeinen Bestand zurück. + * + * Nicht automatisch beim Kundenaustritt (siehe Migrationskommentar): sonst + * verschwände eine Maschine kommentarlos aus dem Bestand, ohne dass ein + * Operator sie tatsächlich freigegeben hat. + */ + public function releaseReservation(): void + { + $this->authorize('hosts.manage'); + $this->host->update(['reserved_for_customer_id' => null]); + $this->dispatch('notify', message: __('hosts.detail.reserved_released')); + } + + /** ConfirmReleaseReservation dispatches this back (R23) — see that class. */ + #[On('host-reservation-release-confirmed')] + public function onReleaseConfirmed(): void + { + $this->releaseReservation(); + } + public function retry(): void { $this->authorize('hosts.manage'); @@ -161,6 +202,10 @@ class HostDetail extends Component ], 'version' => PveVersion::parse($this->host->pve_version), 'fqdn' => HostName::fqdn($this->host->name), + // Nur gebraucht, solange der Host noch niemandem gehört — aber + // billig genug (eine Namensliste), um sie immer mitzugeben statt + // eine zweite Bedingung ums Laden zu ziehen. + 'customers' => Customer::query()->orderBy('name')->get(), ]); } } diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php index d8e602c..9a7fc89 100644 --- a/app/Livewire/Admin/Hosts.php +++ b/app/Livewire/Admin/Hosts.php @@ -38,7 +38,7 @@ class Hosts extends Component // call — the console container has no wg0 (see VpnPeer::vpnPeer() // and SyncVpnPeers' docblock). One eager-loaded query for the // whole page, not one per host. - ->with('vpnPeer') + ->with('vpnPeer', 'reservedFor') ->when($this->search !== '', function ($q) { $term = '%'.$this->search.'%'; $q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term)); diff --git a/app/Models/Host.php b/app/Models/Host.php index 299962c..5834dd9 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -9,6 +9,7 @@ use Database\Factories\HostFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -23,6 +24,7 @@ class Host extends Model implements ProvisioningSubject 'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey', 'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores', 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id', + 'reserved_for_customer_id', ]; protected $hidden = ['api_token_ref']; @@ -102,6 +104,17 @@ class Host extends Model implements ProvisioningSubject return $this->belongsToMany(MaintenanceWindow::class); } + /** + * Wem diese Maschine als "eigener Server" versprochen ist — null heißt + * allgemeiner Bestand. Bewusst ohne `nullOnDelete`-Kaskade im Modell: die + * Migration löscht die Reservierung schon auf Datenbankebene, wenn der + * Kunde verschwindet (siehe deren Kopfkommentar). + */ + public function reservedFor(): BelongsTo + { + return $this->belongsTo(Customer::class, 'reserved_for_customer_id'); + } + /** Free committable storage: total minus reserve. */ public function freeGb(): int { @@ -225,17 +238,33 @@ class Host extends Model implements ProvisioningSubject /** * Placement (spec §1): first active host in the datacenter with enough free * committable storage. Cluster is ignored in v1.0. + * + * Reservierte Hosts gehören ihrem Kunden. Ohne den dritten Parameter ist + * die Frage "wer aus dem allgemeinen Bestand kann das tragen" — und die + * Antwort darf keine Maschine sein, die bereits jemandem versprochen ist. */ - public static function placeableIn(string $datacenter, int $quotaGb): ?self + public static function placeableIn(string $datacenter, int $quotaGb, ?int $forCustomerId = null): ?self { return self::query() ->where('datacenter', $datacenter) ->where('status', 'active') + ->unreserved($forCustomerId) ->orderBy('name') ->get() ->first(fn (self $host) => $host->availableGb() >= $quotaGb); } + /** + * Hosts, die für diese Platzierung in Frage kommen: der allgemeine Bestand + * und die eigenen. + */ + public function scopeUnreserved(Builder $query, ?int $forCustomerId = null): Builder + { + return $query->where(fn (Builder $q) => $q + ->whereNull('reserved_for_customer_id') + ->when($forCustomerId !== null, fn (Builder $inner) => $inner->orWhere('reserved_for_customer_id', $forCustomerId))); + } + /** Could this particular host take an instance of this size right now? */ public function canTake(int $quotaGb): bool { diff --git a/app/Provisioning/Steps/Customer/ReserveResources.php b/app/Provisioning/Steps/Customer/ReserveResources.php index 0580c7c..aa8a170 100644 --- a/app/Provisioning/Steps/Customer/ReserveResources.php +++ b/app/Provisioning/Steps/Customer/ReserveResources.php @@ -93,7 +93,7 @@ class ReserveResources extends CustomerStep // answered, and they would find out from the finished instance. $host = $pinned !== null ? ($pinned->canTake((int) $plan['disk_gb']) ? $pinned : null) - : Host::placeableIn($order->datacenter, (int) $plan['disk_gb']); + : Host::placeableIn($order->datacenter, (int) $plan['disk_gb'], $order->customer_id); if ($host === null) { return null; diff --git a/app/Services/Provisioning/HostCapacity.php b/app/Services/Provisioning/HostCapacity.php index c35efd1..fa661c8 100644 --- a/app/Services/Provisioning/HostCapacity.php +++ b/app/Services/Provisioning/HostCapacity.php @@ -42,6 +42,9 @@ class HostCapacity { return (int) Host::query() ->where('status', 'active') + // Der allgemeine Bestand. Eine reservierte Maschine hat Platz, aber + // nicht für den, der hier fragt. + ->unreserved() ->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter)) ->get() ->map(fn (Host $host) => $host->availableGb()) @@ -74,6 +77,7 @@ class HostCapacity // per sellable package before they had done anything at all. $hasHost = Host::query() ->where('status', 'active') + ->unreserved() ->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter)) ->exists(); diff --git a/database/migrations/2026_08_01_000003_add_reservation_to_hosts.php b/database/migrations/2026_08_01_000003_add_reservation_to_hosts.php new file mode 100644 index 0000000..6ff929b --- /dev/null +++ b/database/migrations/2026_08_01_000003_add_reservation_to_hosts.php @@ -0,0 +1,36 @@ +foreignId('reserved_for_customer_id')->nullable()->after('datacenter') + ->constrained('customers')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('hosts', function (Blueprint $table) { + $table->dropConstrainedForeignId('reserved_for_customer_id'); + }); + } +}; diff --git a/lang/de/hosts.php b/lang/de/hosts.php index e0658ed..2f15420 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -56,6 +56,9 @@ return [ 'capacity' => 'Kapazität', 'free' => 'frei', 'unknown' => '—', + // Kurzform für Zeilen (Host-Liste, Kapazitätsseite) — dieselbe wie in + // 'detail.reserved_for', aber klein genug für eine Tabellenzelle. + 'reserved_badge' => 'reserviert für :name', 'meta' => [ 'cores' => 'Kerne', 'ram' => 'RAM', @@ -106,6 +109,14 @@ return [ 'reserve_saved' => 'Reserve aktualisiert.', 'drain' => 'In Wartung', 'activate' => 'Aktivieren', + + 'reserved_general' => 'Allgemeiner Bestand.', + 'reserved_for' => 'Reserviert für :name.', + 'reserved_pick' => 'Kunde wählen …', + 'reserved_save' => 'Reservieren', + 'reserved_saved' => 'Für :name reserviert.', + 'reserved_release' => 'Lösen', + 'reserved_released' => 'Reservierung gelöst.', ], 'istatus' => [ @@ -153,6 +164,12 @@ return [ 'secure_host_firewall' => 'Host-Firewall absichern', 'complete_host_onboarding' => 'Onboarding abschließen', ], + 'release_reservation' => [ + 'title' => 'Reservierung lösen?', + 'body' => ':host wird wieder allgemeiner Bestand — bisher reserviert für :customer. Der nächste Startkunde kann darauf landen.', + 'confirm' => 'Lösen', + ], + 'reissue' => [ 'action' => 'Befehlszeile neu ausstellen', 'title' => 'Neue Befehlszeile ausstellen?', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 138bdbf..685186b 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -56,6 +56,9 @@ return [ 'capacity' => 'Capacity', 'free' => 'free', 'unknown' => '—', + // Short form for rows (host list, capacity page) — same idea as + // 'detail.reserved_for', just small enough for a table cell. + 'reserved_badge' => 'reserved for :name', 'meta' => [ 'cores' => 'Cores', 'ram' => 'RAM', @@ -106,6 +109,14 @@ return [ 'reserve_saved' => 'Reserve updated.', 'drain' => 'Maintenance', 'activate' => 'Activate', + + 'reserved_general' => 'General stock.', + 'reserved_for' => 'Reserved for :name.', + 'reserved_pick' => 'Pick a customer …', + 'reserved_save' => 'Reserve', + 'reserved_saved' => 'Reserved for :name.', + 'reserved_release' => 'Release', + 'reserved_released' => 'Reservation released.', ], 'istatus' => [ @@ -153,6 +164,12 @@ return [ 'secure_host_firewall' => 'Secure host firewall', 'complete_host_onboarding' => 'Complete onboarding', ], + 'release_reservation' => [ + 'title' => 'Release the reservation?', + 'body' => ':host becomes general stock again — previously reserved for :customer. The next new customer could land on it.', + 'confirm' => 'Release', + ], + 'reissue' => [ 'action' => 'Re-issue the command line', 'title' => 'Issue a new command line?', diff --git a/resources/views/livewire/admin/capacity.blade.php b/resources/views/livewire/admin/capacity.blade.php index cbcd160..f9515aa 100644 --- a/resources/views/livewire/admin/capacity.blade.php +++ b/resources/views/livewire/admin/capacity.blade.php @@ -107,7 +107,16 @@ @foreach ($hosts as $host) - {{ $host->name }} + + {{ $host->name }} + {{-- Erklärt statt versteckt: largestPlaceableGb() unten zählt + diese Zeile nicht mit — sie gehört bereits einem Kunden. --}} + @if ($host->reserved_for_customer_id) + + {{ __('hosts.reserved_badge', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }} + + @endif + {{ $host->datacenter }} {{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('capacity.free') }} diff --git a/resources/views/livewire/admin/confirm-release-reservation.blade.php b/resources/views/livewire/admin/confirm-release-reservation.blade.php new file mode 100644 index 0000000..f836040 --- /dev/null +++ b/resources/views/livewire/admin/confirm-release-reservation.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('hosts.release_reservation.title') }}

+

{{ __('hosts.release_reservation.body', ['host' => $hostName, 'customer' => $customerName]) }}

+
+
+
+ {{ __('hosts.cancel') }} + + {{ __('hosts.release_reservation.confirm') }} + +
+
diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index b39d4d1..3f05b5e 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -195,6 +195,34 @@ {{ __('hosts.detail.reserve_hint') }} @endif + + {{-- Reservierung: "eigener Server" ist sonst nur ein Satz im Angebot. + Erst diese Markierung haelt andere Kunden fern (Host::placeableIn()). --}} +
+ @if ($host->reserved_for_customer_id) + {{ __('hosts.detail.reserved_for', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }} + + @else + {{ __('hosts.detail.reserved_general') }} +
+ + +
+ @endif +
{{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 1e8d938..693656e 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -121,6 +121,13 @@ @else {{ __('hosts.unknown') }} @endif + {{-- Steht getrennt da, statt in der Zahl darüber zu verschwinden: + diese Maschine zählt nicht zum allgemeinen Bestand (Host::placeableIn()). --}} + @if ($host->reserved_for_customer_id) + + {{ __('hosts.reserved_badge', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }} + + @endif {{ __('hosts.status.'.$host->status) }} diff --git a/tests/Feature/Admin/HostReservationTest.php b/tests/Feature/Admin/HostReservationTest.php new file mode 100644 index 0000000..511736d --- /dev/null +++ b/tests/Feature/Admin/HostReservationTest.php @@ -0,0 +1,147 @@ +create(); + $fremder = Customer::factory()->create(); + + Host::factory()->active()->create([ + 'datacenter' => 'fsn', 'total_gb' => 4000, + 'reserved_for_customer_id' => $mieter->id, + ]); + + expect(Host::placeableIn('fsn', 100, $fremder->id))->toBeNull() + ->and(Host::placeableIn('fsn', 100, $mieter->id))->not->toBeNull(); +}); + +it('zählt einen reservierten Host nicht zum allgemeinen Bestand', function () { + $mieter = Customer::factory()->create(); + + Host::factory()->active()->create([ + 'datacenter' => 'fsn', 'total_gb' => 4000, + 'reserved_for_customer_id' => $mieter->id, + ]); + + expect(app(HostCapacity::class)->largestPlaceableGb('fsn'))->toBe(0) + ->and(app(HostCapacity::class)->canPlace('fsn', 40))->toBeFalse(); +}); + +it('lässt einen freien Host frei', function () { + Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 500]); + + expect(app(HostCapacity::class)->canPlace('fsn', 40))->toBeTrue(); +}); + +/** + * Die Konsole: setzen und lösen. + * + * Setzen braucht keine Bestätigung — ein Host, der noch niemandem gehört, + * bekommt einen Besitzer, nichts geht verloren. Lösen schon (R23): danach + * ist die Maschine wieder allgemeiner Bestand, und der nächste Startkunde + * kann darauf landen. + */ +it('reserviert einen Host für einen Kunden von der Konsole aus', function () { + $host = Host::factory()->active()->create(); + $customer = Customer::factory()->create(); + + Livewire::actingAs(admin(), 'operator') + ->test(HostDetail::class, ['host' => $host]) + ->call('reserveFor', $customer->uuid) + ->assertDispatched('notify'); + + expect($host->fresh()->reserved_for_customer_id)->toBe($customer->id); +}); + +it('verweigert das Reservieren ohne hosts.manage', function () { + $host = Host::factory()->active()->create(); + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(HostDetail::class, ['host' => $host]) + ->call('reserveFor', $customer->uuid) + ->assertForbidden(); + + expect($host->fresh()->reserved_for_customer_id)->toBeNull(); +}); + +it('bestätigt das Lösen im eigenen Modal, ohne dabei selbst etwas zu ändern', function () { + $mieter = Customer::factory()->create(['name' => 'Acme GmbH']); + $host = Host::factory()->active()->create(['reserved_for_customer_id' => $mieter->id]); + + Livewire::actingAs(admin(), 'operator') + ->test(ConfirmReleaseReservation::class, ['uuid' => $host->uuid]) + ->assertSee('Acme GmbH') + ->call('confirmRelease') + ->assertDispatched('host-reservation-release-confirmed'); + + // Das Modal selbst rührt nichts an — die Reservierung steht noch. + expect($host->fresh()->reserved_for_customer_id)->toBe($mieter->id); +}); + +it('löst die Reservierung, sobald die Seite das bestätigte Ereignis empfängt', function () { + $mieter = Customer::factory()->create(); + $host = Host::factory()->active()->create(['reserved_for_customer_id' => $mieter->id]); + + Livewire::actingAs(admin(), 'operator') + ->test(HostDetail::class, ['host' => $host]) + ->call('onReleaseConfirmed') + ->assertDispatched('notify'); + + expect($host->fresh()->reserved_for_customer_id)->toBeNull(); +}); + +/** + * Erklärt statt versteckt (Aufgabenauflösung #4): eine reservierte Maschine + * verschwindet nirgends kommentarlos aus dem Bestand — Host-Detailseite, + * Host-Liste und Kapazitätsseite weisen sie jeweils als solche aus. + */ +it('zeigt die Reservierung auf der Host-Detailseite', function () { + $mieter = Customer::factory()->create(['name' => 'Contoso KG']); + $host = Host::factory()->active()->create(['reserved_for_customer_id' => $mieter->id]); + + $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host)) + ->assertOk() + ->assertSee(__('hosts.detail.reserved_for', ['name' => 'Contoso KG'])); +}); + +it('bietet auf der Host-Detailseite eine Kundenauswahl, solange nichts reserviert ist', function () { + $host = Host::factory()->active()->create(); + $customer = Customer::factory()->create(['name' => 'Globex AG']); + + $this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host)) + ->assertOk() + ->assertSee(__('hosts.detail.reserved_general')) + ->assertSee('Globex AG'); +}); + +it('weist die Reservierung auf der Host-Liste aus', function () { + $mieter = Customer::factory()->create(['name' => 'Umbrella Corp']); + Host::factory()->active()->create(['name' => 'pve-reserved-1', 'reserved_for_customer_id' => $mieter->id]); + + $this->actingAs(admin(), 'operator')->get(route('admin.hosts')) + ->assertOk() + ->assertSee(__('hosts.reserved_badge', ['name' => 'Umbrella Corp'])); +}); + +it('weist die Reservierung auf der Kapazitätsseite aus', function () { + App\Models\Datacenter::factory()->create(['code' => 'fsn2']); + $mieter = Customer::factory()->create(['name' => 'Initech Ltd']); + Host::factory()->active()->create(['datacenter' => 'fsn2', 'reserved_for_customer_id' => $mieter->id]); + + $this->actingAs(admin(), 'operator')->get(route('admin.capacity')) + ->assertOk() + ->assertSee(__('hosts.reserved_badge', ['name' => 'Initech Ltd'])); +});