CluPilotCloud/tests/Feature/Billing/StorageAllowanceTest.php

572 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Actions\ApplyPlanChange;
use App\Actions\BookAddon;
use App\Livewire\Billing;
use App\Livewire\ConfirmBookStorage;
use App\Models\Host;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Provisioning\Steps\Customer\GrowGuestFilesystem;
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\StorageAllowance;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Extra storage, from the purchase to the byte.
*
* A booked pack was a row in `subscription_addons` and nothing else: charged
* every month, and delivered nowhere. Nextcloud's quota came from the package
* alone, the disk was sized for the package alone, and on the rare occasion a
* disk did grow — a plan upgrade — the guest never saw it, because nothing
* stretched the partition or the filesystem over the new space.
*
* These prove the whole chain: the allowance is one figure everything reads, the
* machine really gets the space, the guest really sees it, and a customer whose
* downgrade is blocked by their own data is offered a way out that works.
*/
/** A customer on a package, with a machine that is built, running and served. */
function storageFixture(string $plan = 'team', array $instanceAttributes = []): array
{
// Sized well above anything this file books: since Task 3, BookAddon asks
// the host before it books, and `active()`'s default 1000 GB is too small
// for the grandfathered Enterprise machine some of these tests stand up —
// placeableIn() would never have put one there.
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 2000]);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
$subscription = $order->subscription;
$customer = $order->customer;
$user = User::factory()->create(['email' => $customer->email]);
$instance = Instance::factory()->create(array_merge([
'order_id' => $order->id,
'customer_id' => $customer->id,
'host_id' => $host->id,
'vmid' => 101,
'subdomain' => 'berger',
'plan' => $plan,
'quota_gb' => $subscription->quota_gb,
'disk_gb' => $subscription->disk_gb,
'ram_mb' => $subscription->ram_mb,
'cores' => $subscription->cores,
'status' => 'active',
'route_written' => true,
'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone()],
'cert_ok' => true,
], $instanceAttributes));
$subscription->update(['instance_id' => $instance->id]);
// Some of these tests book storage packs through Billing::purchase(),
// which since Task 5 refuses outright without a Stripe key for the
// active mode.
withStripeSecret();
return [
'host' => $host,
'order' => $order,
'customer' => $customer,
'user' => $user,
'subscription' => $subscription->fresh(),
'instance' => $instance,
];
}
/** A run of the storage pipeline against that machine, ready for a step. */
function storageRun(array $fixture): ProvisioningRun
{
return ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $fixture['order']->id,
'pipeline' => 'storage',
'context' => [
'instance_id' => $fixture['instance']->id,
'host_id' => $fixture['host']->id,
'node' => 'pve',
'vmid' => 101,
'subdomain' => $fixture['instance']->subdomain,
],
]);
}
/** What a guest answers when it is asked about its data filesystem. */
function scriptGuestFilesystem($pve, string $source = '/dev/sda1', string $type = 'ext4', string $target = '/'): void
{
$pve->guestScript('findmnt', 0, "{$source} {$type} {$target}");
}
beforeEach(function () {
fakeServices();
// The delivery run is created and left for a worker: these tests are about
// what the steps do when they run, not about the queue.
Queue::fake();
});
// ─── A. one authority for the allowance ──────────────────────────────────────
it('raises the allowance with a booked pack and tells Nextcloud the larger figure', function () {
$fixture = storageFixture('team'); // 85 GB package
$pve = app(ProxmoxClient::class);
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
// 85 + 20 (ein Block, Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(105);
expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance');
expect($pve->guestRan("files default_quota --value='105 GB'"))->toBeTrue()
// Recorded as applied, so the sweep can tell an enforced machine from
// one where the figure has only ever been a row in our database.
->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(105)
->and($fixture['instance']->fresh()->quotaIsEnforced())->toBeTrue();
});
it('stacks two packs and lowers the allowance again when one is cancelled', function () {
$fixture = storageFixture('team');
$contract = $fixture['subscription'];
$first = app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
// 85 + 2 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(125);
app(BookAddon::class)->cancel($first);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(105)
// Cancelled, never deleted: what somebody was paying, and until when, is
// evidence.
->and(SubscriptionAddon::query()->count())->toBe(2);
});
it('counts a pack booked with a quantity of more than one', function () {
$fixture = storageFixture('team');
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 3);
// 85 + 3 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(145);
});
it('shows the whole allowance in the portal, not the package alone', function () {
$fixture = storageFixture('team');
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
// 85 + 2 × 20.
->assertSee('125 GB')
->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 40]));
});
it('starts one delivery run when a pack is booked, and none while another is in flight', function () {
$fixture = storageFixture('team');
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1);
// A second booking arrives while the first run is still pending. That run
// reads the allowance when it reaches the quota step, which is this state or
// newer — a second run beside it would resize the same disk twice.
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(1);
});
it('starts a delivery run when a pack is cancelled, so the quota comes back down', function () {
$fixture = storageFixture('team');
$addon = app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
ProvisioningRun::query()->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
app(BookAddon::class)->cancel($addon);
expect(ProvisioningRun::query()->where('pipeline', 'storage')->count())->toBe(2);
});
// ─── B. the machine really gets the space ────────────────────────────────────
it('grows the disk to the allowance plus the package overhead, and the guest with it', function () {
// Team is 85 GB sold on a 100 GB disk: 15 GB of overhead the package was
// sized with, and the pack (Task 5: 22 GB on disk) comes ON TOP of it.
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
scriptGuestFilesystem($pve);
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
$run = storageRun($fixture);
expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance')
->and($pve->resizeCalls)->toContain('101:scsi0:122G')
->and($fixture['instance']->fresh()->disk_gb)->toBe(122);
expect(app(GrowGuestFilesystem::class)->execute($run)->type)->toBe('advance')
->and($pve->guestRan('/sys/class/block/sda/device/rescan'))->toBeTrue()
->and($pve->guestRan("growpart '/dev/sda' '1'"))->toBeTrue()
->and($pve->guestRan("resize2fs '/dev/sda1'"))->toBeTrue();
});
it('does nothing the second time round', function () {
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
scriptGuestFilesystem($pve);
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
$run = storageRun($fixture);
app(ResizeVirtualMachine::class)->execute($run);
app(GrowGuestFilesystem::class)->execute($run);
// Everything is already the right size. growpart answers the way it answers
// when the partition already fills the disk: a non-zero exit with NOCHANGE
// on its output, which is not a failure and must not be read as one.
$pve->resizeCalls = [];
$pve->guestCommands = [];
$pve->guestScript('growpart', 1, 'NOCHANGE: partition 1 is size 1342177280. it cannot be grown');
expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
->and($pve->resizeCalls)->toBe([])
->and($fixture['instance']->fresh()->disk_gb)->toBe(122);
expect(app(GrowGuestFilesystem::class)->execute($run->fresh())->type)->toBe('advance');
});
it('grows an xfs filesystem with its own tool, pointed at the mount point', function () {
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
scriptGuestFilesystem($pve, '/dev/sda2', 'xfs', '/');
expect(app(GrowGuestFilesystem::class)->execute(storageRun($fixture))->type)->toBe('advance')
->and($pve->guestRan("growpart '/dev/sda' '2'"))->toBeTrue()
// The mount point, not the device: that is what xfs_growfs takes.
->and($pve->guestRan("xfs_growfs '/'"))->toBeTrue()
->and($pve->guestRan('resize2fs'))->toBeFalse();
});
it('fails loudly on a filesystem it does not know how to grow', function () {
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
scriptGuestFilesystem($pve, '/dev/sda1', 'reiserfs', '/');
$result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture));
// A failed run somebody can read, never a silent advance: a customer who has
// paid for space that never appeared must not depend on anyone noticing.
expect($result->type)->toBe('fail')
->and($result->reason)->toContain('reiserfs')
->and($pve->guestRan('resize2fs'))->toBeFalse()
->and($pve->guestRan('xfs_growfs'))->toBeFalse();
});
it('waits for a guest that is not answering rather than pretending it grew', function () {
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
$pve->guestAgentUp = false;
$result = app(GrowGuestFilesystem::class)->execute(storageRun($fixture));
expect($result->type)->toBe('retry')
->and($pve->guestCommands)->toBe([]);
});
// ─── C. a blocked downgrade with a way out ───────────────────────────────────
/** A reading of what is actually stored, dated. */
function storedBytes(Instance $instance, int $gib, ?string $day = null): InstanceMetric
{
return InstanceMetric::query()->updateOrCreate(
['instance_id' => $instance->id, 'day' => $day ?? now()->toDateString()],
// Der Datenträger, den die Maschine wirklich hat — eine Messung kommt
// von dort und nicht von einer festen Zahl, die mit jeder Paketleiter
// wieder falsch wird.
['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => $instance->disk_gb * 1024 ** 3],
);
}
it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () {
$fixture = storageFixture('business'); // 175 GB package
// Fünfzig Gigabyte zu viel: drei Blöcke à 20 GB decken sie, und das ist
// genau der Deckel — der letzte Betrag, für den Kaufen überhaupt noch ein
// Ausweg ist. 135 gegen Teams 85 ist derselbe Abstand, den vorher 550 gegen
// 500 hatte; nur die Leiter darunter ist eine andere geworden.
storedBytes($fixture['instance'], 135);
$team = app(PlanCatalogue::class)->sellable()['team']; // 85 GB
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');
expect($check->allowed)->toBeFalse()
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage'])
->and($check->storageRemedy)->not->toBeNull()
->and($check->storageRemedy['limit'])->toBe('85 GB')
->and($check->storageRemedy['packs'])->toBe(3)
->and($check->storageRemedy['pack_gb'])->toBe(20)
// Gedeckt, also kein Restbetrag — und damit der Satz, der zum Knopf
// gehört, und nicht der, der ihn ersetzt.
->and($check->storageRemedy['short'])->toBeNull();
});
it('lets exactly that downgrade through once the offered packs are booked', function () {
$fixture = storageFixture('business');
// Vierzig Gigabyte über Teams 85 sind zwei Blöcke — innerhalb des Deckels
// (Task 2: höchstens drei), und genug, um denselben Ablauf zu belegen.
storedBytes($fixture['instance'], 125);
$team = app(PlanCatalogue::class)->sellable()['team'];
$packs = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->storageRemedy['packs'];
expect($packs)->toBe(2);
foreach (range(1, $packs) as $ignored) {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
}
$after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team');
expect($after->allowed)->toBeTrue()
->and($after->blockers)->toBe([]);
});
/**
* Der Ausweg muss auch kaufmännisch gehbar sein, nicht nur rechnerisch.
*
* `packsToCover()` rechnet, und die Rechnung kennt keinen Deckel: achtzig
* Gigabyte zu viel sind vier Blöcke, und höchstens drei sind buchbar. Die
* Karte bot „4 × Zusatzspeicher buchen“ an, das Modal klemmte still auf drei,
* BookAddon hätte den vierten ohnehin abgelehnt — und der Kunde stand nach 45 €
* im Monat genau dort, wo er vorher stand. Solange ein Block 100 GB brachte,
* war das unerreichbar; seit Task 5 sind es 20, und jede Überschreitung über
* 60 GB landet hier.
*/
it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 165); // 80 GB zu viel für Teams 85 = vier Blöcke
$team = app(PlanCatalogue::class)->sellable()['team'];
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');
expect($check->allowed)->toBeFalse()
// Kein Angebot statt eines unerfüllbaren: die Karte hängt ihren Knopf
// an diese Zahl.
->and($check->storageRemedy['packs'])->toBe(0)
// Und was nach allen drei buchbaren Blöcken übrig bliebe, in Ziffern —
// 20 GiB, nicht die vollen 80, die ohne Blöcke wegmüssten.
->and($check->storageRemedy['short'])->toBe('21,5 GB')
->and($check->storageRemedy['over'])->toBe('85,9 GB');
});
/**
* Der Beleg, dass das alte Versprechen wirklich keines war: Kunde folgt dem
* Rat, kauft das Höchstmaß — und der Wechsel bleibt gesperrt.
*/
it('bleibt gesperrt, auch wenn der Kunde das Höchstmaß an Blöcken bucht', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 165);
foreach (range(1, 3) as $ignored) {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
}
// Der vierte, dem die alte Empfehlung entsprochen hätte, geht nicht.
expect(fn () => app(BookAddon::class)($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))
->toThrow(RuntimeException::class);
$team = app(PlanCatalogue::class)->sellable()['team'];
$after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team');
expect($after->allowed)->toBeFalse()
->and($after->storageRemedy['packs'])->toBe(0)
->and($after->storageRemedy['short'])->toBe('21,5 GB');
});
/** Dieselbe Grenze in der Ansicht: kein Knopf, und der Satz sagt warum. */
it('bietet auf der Karte keinen Kauf an, den der Vertrag nicht abschließen kann', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 165);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertDontSee('confirm-book-storage')
->assertSee(__('billing.downgrade_escape.capped', [
'used' => '177 GB',
'limit' => '85 GB',
'over' => '85,9 GB',
'short' => '21,5 GB',
]))
// Der andere Ausweg bleibt: löschen und neu messen.
->assertSee(__('billing.downgrade_escape.remeasure'));
});
/**
* Der zweite Grund, aus dem BookAddon ablehnt, und derselbe Fehler wäre er
* gewesen: Enterprise wird gar kein Zusatzspeicher verkauft. Der Deckel allein
* hätte hier „3“ geantwortet und die Karte hätte Blöcke angeboten, die es für
* dieses Paket nicht gibt.
*/
it('bietet einem Enterprise-Vertrag keine Blöcke an, den es dort nicht gibt', function () {
// Enterprise wird seit der neuen Leiter angefragt statt gekauft; der
// Vertrag, um den es hier geht, ist einer aus der Zeit davor.
$fixture = asGrandfathered('enterprise', fn () => storageFixture('enterprise'));
storedBytes($fixture['instance'], 375); // 200 GB zu viel für Business' 175
$business = app(PlanCatalogue::class)->sellable()['business'];
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $business, 'business');
expect($check->allowed)->toBeFalse()
->and($check->storageRemedy['packs'])->toBe(0)
// Nichts buchbar, also bleibt der volle Betrag stehen — der Satz nennt
// dieselbe Zahl zweimal, und das ist die ehrliche Auskunft.
->and($check->storageRemedy['short'])->toBe('215 GB')
->and($check->storageRemedy['over'])->toBe('215 GB');
});
/**
* Warum das Klemmen in ConfirmBookStorage (`max(1, min(…))`, das bei nichts
* mehr Buchbarem auf einen Block hochzieht) bewusst stehen bleibt: der Weg
* dahinter ist ohnehin zu. Ein Modal ist per `openModal` direkt erreichbar,
* also wird das nicht angenommen, sondern belegt — beide Gründe, aus denen
* BookAddon ablehnt, halten schon in `purchase()`, bevor eine Bestellzeile
* entsteht.
*/
it('legt über den Bestätigungsweg keine Bestellzeile an, wenn nichts buchbar ist', function () {
$amDeckel = storageFixture('business');
app(BookAddon::class)($amDeckel['subscription'], AddonCatalogue::STORAGE, 3);
// Eigene Adresse: `storageFixture()` vergibt sonst zweimal dieselbe, und
// `instances.subdomain` ist eindeutig.
$enterprise = asGrandfathered('enterprise', fn () => storageFixture('enterprise', ['subdomain' => 'huber']));
withStripeSecret();
foreach ([$amDeckel, $enterprise] as $fixture) {
expect(app(AddonCatalogue::class)->bookableQuantity($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))->toBe(0);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
// Genau das, was ein direkt geöffnetes Modal auslöst.
->call('bookStoragePacks', 1);
expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(0);
}
});
it('offers the packs through a modal, and books exactly the number that was offered', function () {
$fixture = storageFixture('business');
// Sechzig Gigabyte über Teams 85, also genau drei Blöcke — der letzte
// Betrag, für den die Karte überhaupt noch einen Knopf anbietet.
storedBytes($fixture['instance'], 145);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertSee(__('billing.downgrade_escape.remeasure'))
// R23: the confirmation is this product's own dialog, never the
// browser's — the card only opens it.
->assertSee('confirm-book-storage')
->call('bookStoragePacks', 3);
expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3);
});
it('confirms the packs in this product own dialog and raises the event with the number', function () {
Livewire::test(ConfirmBookStorage::class, ['packs' => 3])
->assertSee(__('billing.storage_confirm_title', ['count' => 3]))
->call('proceed')
->assertDispatched('storage-packs-confirmed', packs: 3);
});
it('refuses to put more packs in the cart than the ceiling allows', function () {
$fixture = storageFixture('team');
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->call('bookStoragePacks', 9999);
// Die kaufmännische Grenze (drei Blöcke, Task 2) ist inzwischen die
// engere Klemme — vor ihr war MAX_STORAGE_PACKS die einzige, und dieser
// Test schrieb deren Zahl fest. Der Deckel ändert sich nicht dafür, dass
// dieser Test vor ihm entstand.
expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3);
});
it('passes the check on a fresh reading after data was deleted, without waiting for the night', function () {
$fixture = storageFixture('business');
$pve = app(ProxmoxClient::class);
// Last night's reading: 165 GB stored, which does not fit Team's 85.
storedBytes($fixture['instance'], 165, now()->subDay()->toDateString());
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->allowed)->toBeFalse();
// The customer deletes over 100 GB and asks us to look now.
$pve->guestScript('df -B1', 0, "size used\n".(200 * 1024 ** 3).' '.(60 * 1024 ** 3));
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->call('remeasureStorage')
->assertDispatched('notify', message: __('billing.storage_remeasured'));
expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeTrue();
});
it('says it could not look rather than reporting an unreachable cloud as empty', function () {
$fixture = storageFixture('business');
$pve = app(ProxmoxClient::class);
storedBytes($fixture['instance'], 165, now()->subDay()->toDateString());
$pve->guestAgentUp = false;
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->call('remeasureStorage')
->assertDispatched('notify', message: __('billing.storage_remeasure_unavailable'));
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team')->allowed)->toBeFalse();
});
// ─── the packs survive a change of package ───────────────────────────────────
it('keeps booked packs through a plan change and delivers them on the new package', function () {
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
scriptGuestFilesystem($pve);
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 2);
// The packs were paid for separately and have nothing to do with which
// package the customer is on, so nothing cancels them.
app(ApplyPlanChange::class)($fixture['subscription']->fresh(), 'business');
$instance = $fixture['instance']->fresh();
expect(SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->active()->sum('quantity'))->toBe(2)
// Business is 175 GB; the two packs (Task 5: 20 GB each) are still
// theirs on top of it.
->and(StorageAllowance::for($instance)->totalGb())->toBe(215);
$run = ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->firstOrFail();
app(ResizeVirtualMachine::class)->execute($run);
app(GrowGuestFilesystem::class)->execute($run->fresh());
app(ApplyStorageQuota::class)->execute($run->fresh());
// 215 GB for the customer, on Business's own 25 GB of overhead (175 sold on
// a 200 GB disk) plus the two packs' own 22 GB disk headroom each (44 GB).
expect($pve->resizeCalls)->toContain('101:scsi0:244G')
->and($pve->guestRan("files default_quota --value='215 GB'"))->toBeTrue()
->and($instance->fresh()->quota_applied_gb)->toBe(215);
});