CluPilotCloud/tests/Feature/Billing/StorageAllowanceTest.php

571 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 above the biggest plan this file books (business, 1050 GB disk)
// plus room for the packs these tests add on top: since Task 3, BookAddon
// asks the host before it books, and `active()`'s default 1000 GB is
// already too small to have legitimately hosted a business instance in
// the first place — 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'); // 500 GB package
$pve = app(ProxmoxClient::class);
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
// 500 + 20 (ein Block, Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(520);
expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance');
expect($pve->guestRan("files default_quota --value='520 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(520)
->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);
// 500 + 2 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(540);
app(BookAddon::class)->cancel($first);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(520)
// 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);
// 500 + 3 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(560);
});
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)
->assertSee('540 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 500 GB sold on a 540 GB disk: 40 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:562G')
->and($fixture['instance']->fresh()->disk_gb)->toBe(562);
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(562);
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()],
['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => 1050 * 1024 ** 3],
);
}
it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () {
$fixture = storageFixture('business'); // 1000 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. Vorher standen hier 600 GB, aus der Zeit, als ein Block noch
// 100 GB brachte; seit Task 5 wären das fünf Blöcke und damit keiner.
storedBytes($fixture['instance'], 550);
$team = app(PlanCatalogue::class)->sellable()['team']; // 500 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('500 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');
// Absichtlich NICHT die 600 GB von oben: fünf Blöcke passen nicht mehr
// unter den Deckel (Task 2: höchstens drei), seit ein Block auf 20 GB
// geschrumpft ist (Task 5). Vierzig Gigabyte zu viel sind zwei Blöcke —
// innerhalb des Deckels, und genug, um denselben Ablauf zu belegen.
storedBytes($fixture['instance'], 540);
$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: Hundert
* Gigabyte zu viel waren fünf Blöcke, und höchstens drei sind buchbar. Die
* Karte bot „5 × Zusatzspeicher buchen (+100 GB)“ 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'], 600); // 100 GB zu viel für Team = fünf 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 —
// nicht die vollen 107 GB, die ohne Blöcke wegmüssten.
->and($check->storageRemedy['short'])->toBe('42,9 GB')
->and($check->storageRemedy['over'])->toBe('107 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'], 600);
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('42,9 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'], 600);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertDontSee('confirm-book-storage')
->assertSee(__('billing.downgrade_escape.capped', [
'used' => '644 GB',
'limit' => '500 GB',
'over' => '107 GB',
'short' => '42,9 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 () {
$fixture = storageFixture('enterprise');
storedBytes($fixture['instance'], 1200); // 200 GB zu viel für Business
$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 = 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 zu viel, also genau drei Blöcke. Vorher 800 GB, was
// damals „drei Blöcke zu wenig“ hieß und seit Task 5 fünfzehn wären — die
// Karte böte dafür gar keinen Knopf mehr an, und der Test hätte das Modal
// gesucht, das sie zu Recht nicht mehr öffnet.
storedBytes($fixture['instance'], 560);
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: 600 GB stored, which does not fit Team.
storedBytes($fixture['instance'], 600, now()->subDay()->toDateString());
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->allowed)->toBeFalse();
// The customer deletes 300 GB and asks us to look now.
$pve->guestScript('df -B1', 0, "size used\n".(1050 * 1024 ** 3).' '.(300 * 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'], 600, 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 1000 GB; the two packs (Task 5: 20 GB each) are still
// theirs on top of it.
->and(StorageAllowance::for($instance)->totalGb())->toBe(1040);
$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());
// 1040 GB for the customer, on Business's own 50 GB of overhead plus the
// two packs' own 22 GB disk headroom each (44 GB).
expect($pve->resizeCalls)->toContain('101:scsi0:1094G')
->and($pve->guestRan("files default_quota --value='1040 GB'"))->toBeTrue()
->and($instance->fresh()->quota_applied_gb)->toBe(1040);
});