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); });