571 lines
25 KiB
PHP
571 lines
25 KiB
PHP
<?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);
|
||
});
|