CluPilotCloud/tests/Feature/Billing/ApplyPlanChangeTest.php

335 lines
14 KiB
PHP

<?php
use App\Actions\ApplyPlanChange;
use App\Livewire\Admin\Instances;
use App\Livewire\Cloud;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Provisioning\Steps\Customer\ApplyStorageQuota;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Provisioning\Steps\Customer\ResizeVirtualMachine;
use App\Services\Billing\CustomDomainAccess;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* A plan change that actually happens.
*
* PlanChange could price a move and say whether it was allowed; nothing carried
* one out. A customer could buy an upgrade, watch the order sit there paid, and
* keep the same contract, the same machine and the same quota — the order was
* created by the shop and consumed by nobody.
*
* These prove the four halves of a change landing (contract, register,
* entitlements, machine), both triggers, and the two things that cannot be done
* naively to a live customer: shrinking a disk, and rebooting their cloud.
*/
/** A customer on a package, with a machine that is built, running and served. */
function planChangeFixture(string $plan = 'business', array $instanceAttributes = []): array
{
$host = Host::factory()->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. */
function planChangeOrder(array $fixture, string $plan, string $type): Order
{
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);
});