CluPilotCloud/tests/Feature/Admin/HostCapacityTest.php

102 lines
4.5 KiB
PHP

<?php
use App\Livewire\Admin\Overview;
use App\Models\Host;
use App\Models\Instance;
use App\Services\Provisioning\HostCapacity;
use Livewire\Livewire;
/**
* Saying so before a paid order finds out.
*
* The platform books thick: a package reserves its whole `disk_gb` on ONE host
* at placement. So the catalogue can go on offering something no machine has
* room for, and the first anyone hears of it is a paid order failing at the
* reservation step — after the money has moved.
*/
beforeEach(function () {
// hosts.datacenter is a foreign key onto datacenters.code, so the code has
// to exist before a host can carry it.
App\Models\Datacenter::factory()->create(['code' => 'fsn1']);
});
it('measures the largest single reservation the estate could take', function () {
// The largest free HOST, not the sum. A package goes on one machine, so two
// hosts with 300 GB each cannot take a 540 GB instance between them.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 300, 'reserve_pct' => 0]);
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 300, 'reserve_pct' => 0]);
expect(app(HostCapacity::class)->largestPlaceableGb('fsn1'))->toBe(300)
->and(app(HostCapacity::class)->canPlace('fsn1', 540))->toBeFalse();
});
it('counts what is already committed, not what is on the disk', function () {
// Thick booking: an instance holds its whole disk_gb whether the customer
// has put a byte in it or not.
$host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
Instance::factory()->create(['host_id' => $host->id, 'status' => 'active', 'disk_gb' => 700]);
expect(app(HostCapacity::class)->largestPlaceableGb('fsn1'))->toBe(300);
});
it('respects the reserve the operator set aside', function () {
// reserve_pct is headroom for snapshots and for the host's own growth. A
// capacity answer that spends it is not an answer.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 20]);
expect(app(HostCapacity::class)->largestPlaceableGb('fsn1'))->toBe(800);
});
it('warns on the console about a package that can no longer be placed', function () {
// The whole point of the feature: this is the sentence that has to appear
// BEFORE somebody pays for the package it names.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 400, 'reserve_pct' => 0]);
$atRisk = app(HostCapacity::class)->unplaceablePlans();
expect($atRisk)->not->toBeEmpty();
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
$page->assertViewHas('notices', fn (array $notices) => collect($notices)
->contains(fn (array $n) => str_contains($n['text'], 'Host')));
});
it('says nothing while everything still fits', function () {
// A warning that is always there is furniture. This one has to mean
// something the moment it appears.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 20000, 'reserve_pct' => 0]);
expect(app(HostCapacity::class)->unplaceablePlans())->toBeEmpty();
});
it('does not take the overview down when the catalogue cannot be read', function () {
// A capacity warning does not get to be the thing that breaks the page an
// operator opens when something is already wrong.
// PlanCatalogue is final — deliberately, it is the single reader of the
// price tables — so it cannot be subclassed. The call site does not
// type-hint what comes back from the container, which is enough to put a
// throwing stand-in in its place.
app()->bind(App\Services\Billing\PlanCatalogue::class, fn () => new class
{
public function sellable(): array
{
throw new RuntimeException('overlapping availability windows');
}
});
expect(app(HostCapacity::class)->unplaceablePlans())->toBe([]);
Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class)->assertOk();
});
it('does not shout at an installation that has no host yet', function () {
// Zero hosts is not a capacity problem — it is a machine nobody has
// onboarded, and that has its own notice. Without this guard a fresh
// console greeted the operator with one warning per sellable package
// before they had done anything at all.
App\Models\Host::query()->delete();
expect(app(HostCapacity::class)->unplaceablePlans())->toBe([]);
});