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