active()->create(['datacenter' => 'fsn', 'node' => 'pve']); $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); $subscription = $order->subscription; $instance = Instance::factory()->create(array_merge([ 'order_id' => $order->id, 'customer_id' => $order->customer_id, 'host_id' => $host->id, 'vmid' => 101, 'guest_ip' => '10.20.0.7', '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]); return ['host' => $host, 'order' => $order, 'subscription' => $subscription->fresh(), 'instance' => $instance]; } /** * What the shop writes when somebody picks another package: the cart entry, and * — for a downgrade — the booking stamped onto the contract with the date it * comes due. Both, because the scheduler reads the contract and the customer * reads the cart, and a fixture that wrote only one of them would be testing a * state the shop never produces. */ function planChangeOrder(array $fixture, string $plan, string $type): Order { if ($type === 'downgrade') { $fixture['subscription']->bookPendingPlanChange($plan); } return Order::create([ 'customer_id' => $fixture['order']->customer_id, 'plan' => $plan, 'type' => $type, 'amount_cents' => 1000, 'currency' => 'EUR', 'datacenter' => 'fsn', 'status' => 'pending', ]); } /** The run the change started, whatever step it is on. */ function planChangeRun(): ?ProvisioningRun { return ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->first(); } it('moves the contract onto the new package, records it once, and starts one run when the upgrade is paid', function () { fakeServices(); Queue::fake(); $fixture = planChangeFixture('team'); $order = planChangeOrder($fixture, 'business', 'upgrade'); $periodEnd = $fixture['subscription']->current_period_end; // The trigger: the order becoming paid, which is all a mocked payment is. $order->update(['status' => 'paid']); $business = Subscription::snapshotFrom('business'); $contract = $fixture['subscription']->fresh(); expect($contract->plan)->toBe('business') ->and($contract->plan_version_id)->toBe($business['plan_version_id']) ->and($contract->quota_gb)->toBe($business['quota_gb']) ->and($contract->price_cents)->toBe($business['price_cents']) // Term and billing period are the customer's, not the package's. ->and($contract->term)->toBe(Subscription::TERM_MONTHLY) ->and($contract->current_period_end->eq($periodEnd))->toBeTrue() ->and($contract->started_at->eq($fixture['subscription']->started_at))->toBeTrue(); expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1) ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1) ->and($order->fresh()->status)->toBe('applied') ->and($fixture['instance']->fresh()->plan)->toBe('business'); // The run hangs off the machine's own purchase order, like `address` does, // so the steps can find this customer's contract and no second run can be // started against the same machine beside it. expect(planChangeRun()->subject_id)->toBe($fixture['order']->id); }); it('changes nothing the second time the same order is applied', function () { fakeServices(); Queue::fake(); $fixture = planChangeFixture('team'); $order = planChangeOrder($fixture, 'business', 'upgrade'); $order->update(['status' => 'paid']); $versionBefore = $fixture['subscription']->fresh()->plan_version_id; // Straight back through the action, as a retried trigger or a second // scheduler tick would come. expect(app(ApplyPlanChange::class)->forOrder($order->fresh()))->toBeNull(); expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1) ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1) ->and($fixture['subscription']->fresh()->plan_version_id)->toBe($versionBefore); }); it('holds a downgrade until the term is over, then applies it from the scheduler', function () { fakeServices(); Queue::fake(); $fixture = planChangeFixture('business'); $order = planChangeOrder($fixture, 'team', 'downgrade'); $end = $fixture['subscription']->current_period_end; // Mid-term: the customer bought this month, and a month is a month. Carbon::setTestNow($end->copy()->subDays(3)); $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); expect($fixture['subscription']->fresh()->plan)->toBe('business') ->and($order->fresh()->status)->toBe('pending') ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0) ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(0); // Once the term has run out it lands, and running the command again does // not land it a second time. Carbon::setTestNow($end->copy()->addHour()); $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); expect($fixture['subscription']->fresh()->plan)->toBe('team') ->and($order->fresh()->status)->toBe('applied') ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(1) ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1); Carbon::setTestNow(); }); it('stops serving a domain the smaller package cannot carry', function () { $services = fakeServices(); Queue::fake(); $domain = 'cloud.berger.at'; $fixture = planChangeFixture('business', [ 'custom_domain' => $domain, 'domain_token' => 'tok', 'domain_verified_at' => now(), 'domain_cert_ok' => true, 'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone(), $domain], ]); $customer = $fixture['order']->customer; $access = app(CustomDomainAccess::class); // Business carries an own domain; Start cannot have one at all. expect($access->allowsCustomer($customer))->toBeTrue(); $order = planChangeOrder($fixture, 'start', 'downgrade'); Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour()); $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); expect($access->allowsCustomer($customer->fresh()))->toBeFalse(); // And the machine is told, by this run rather than by a second one racing // it: the address steps are part of the plan-change pipeline. expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0); $run = planChangeRun(); app(ConfigureDnsAndTls::class)->execute($run); app(ConfigureNextcloud::class)->execute($run); expect($services['traefik']->serves('berger', $domain))->toBeFalse() ->and($services['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue() ->and($order->fresh()->status)->toBe('applied'); Carbon::setTestNow(); }); it('shrinks the quota on a downgrade and never the disk', function () { $services = fakeServices(); Queue::fake(); $fixture = planChangeFixture('business'); $diskBefore = (int) $fixture['instance']->disk_gb; // 1050 GB, and it stays $order = planChangeOrder($fixture, 'start', 'downgrade'); Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour()); $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); $run = planChangeRun(); app(ResizeVirtualMachine::class)->execute($run); app(ApplyStorageQuota::class)->execute($run); $start = Subscription::snapshotFrom('start'); $instance = $fixture['instance']->fresh(); // Proxmox grows a disk and cannot shrink one, so nothing asked it to — and // the row still states the size the machine actually holds, because the // host's storage accounting reads it. expect($services['pve']->resizeCalls)->toBe([]) ->and($instance->disk_gb)->toBe($diskBefore) ->and($instance->disk_gb)->toBeGreaterThan((int) $start['disk_gb']); // What was sold does shrink, and Nextcloud is what enforces it. expect($instance->quota_gb)->toBe($start['quota_gb']) ->and($services['pve']->guestRan("config:app:set files default_quota --value='100 GB'"))->toBeTrue(); Carbon::setTestNow(); }); it('grows the disk on an upgrade', function () { $services = fakeServices(); Queue::fake(); $fixture = planChangeFixture('team'); planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']); app(ResizeVirtualMachine::class)->execute(planChangeRun()); expect($services['pve']->resizeCalls)->toBe(['101:scsi0:1050G']) ->and($fixture['instance']->fresh()->disk_gb)->toBe(1050); }); it('refuses an upgrade on a contract that is no longer active', function () { fakeServices(); Queue::fake(); $fixture = planChangeFixture('team'); $fixture['subscription']->update(['status' => 'cancelled', 'cancelled_at' => now()]); $order = planChangeOrder($fixture, 'business', 'upgrade'); $order->update(['status' => 'paid']); // Nothing at all: a customer who has left is not upgraded, billed or built. expect($fixture['subscription']->fresh()->plan)->toBe('team') ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(0) ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0) // Still the customer's to remove from the cart, rather than silently // marked as though it had been carried out. ->and($order->fresh()->status)->toBe('paid'); }); it('never reboots a live machine, and says so where a human looks', function () { $services = fakeServices(); Queue::fake(); $fixture = planChangeFixture('team'); $services['pve']->runningVmids = [101]; planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']); app(ResizeVirtualMachine::class)->execute($run = planChangeRun()); $instance = $fixture['instance']->fresh(); // The configuration carries the bigger machine… expect($services['pve']->cloudInitParams[101]['cores'])->toBe(8) ->and($services['pve']->cloudInitParams[101]['memory'])->toBe(16384) // …the guest was left running, untouched: startVm appends to this list, // so an unchanged list is a machine that was never stopped and started. ->and($services['pve']->runningVmids)->toBe([101]) // …and the gap is recorded rather than left for somebody to discover. ->and($instance->restartIsPending())->toBeTrue() ->and($run->events()->where('outcome', 'info')->where('step', 'resize_vm')->exists())->toBeTrue(); // The customer is told on the page that shows them their cloud. $user = User::factory()->create(['email_verified_at' => now()]); $fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]); Livewire::actingAs($user)->test(Cloud::class) ->assertSee(__('cloud.restart_pending_title')); // And the console's instance list carries it beside the status. Livewire::actingAs(admin(), 'operator')->test(Instances::class) ->assertSee(__('admin.restart_pending')); }); it('has nothing pending when the machine is stopped, because starting it applies the new size', function () { $services = fakeServices(); Queue::fake(); // Stopped, and carrying a pending restart from an earlier change. $fixture = planChangeFixture('team', ['restart_required_since' => now()->subDay()]); $services['pve']->runningVmids = []; planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']); app(ResizeVirtualMachine::class)->execute(planChangeRun()); expect($fixture['instance']->fresh()->restartIsPending())->toBeFalse(); }); it('refuses to be moved onto a package the catalogue will not sell', function () { fakeServices(); Queue::fake(); $fixture = planChangeFixture('team'); expect(app(ApplyPlanChange::class)($fixture['subscription'], 'gibtsnicht'))->toBeNull() ->and($fixture['subscription']->fresh()->plan)->toBe('team') ->and(SubscriptionRecord::query()->whereIn('event', [ SubscriptionRecord::EVENT_UPGRADE, SubscriptionRecord::EVENT_DOWNGRADE, ])->count())->toBe(0); });