CluPilotCloud/tests/Feature/DowngradeTest.php

104 lines
3.9 KiB
PHP

<?php
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\Seat;
use App\Models\User;
use App\Services\Billing\DowngradeCheck;
use Livewire\Livewire;
/**
* Moving down to a smaller plan.
*
* A downgrade is not the mirror of an upgrade: going up always fits, going
* down can ask the instance to hold more than the target allows. What matters
* as much as refusing it is SAYING WHY, in the customer's own numbers — a
* disabled button that does not name the obstacle is the thing people ring
* about.
*/
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
{
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id]);
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
foreach (range(1, $seats) as $n) {
Seat::create([
'customer_id' => $customer->id,
'email' => "seat{$n}@example.test",
'role' => $n === 1 ? 'owner' : 'member',
'status' => 'active',
]);
}
if ($usedBytes !== null) {
InstanceMetric::create([
'instance_id' => $instance->id,
'day' => now()->toDateString(),
'disk_used_bytes' => $usedBytes,
'disk_total_bytes' => 500 * 1024 ** 3,
]);
}
// One test below calls Billing::purchase(), which since Task 5 refuses
// outright without a Stripe key for the active mode. Without this, that
// test would see the same "no order" result for the wrong reason — the
// Stripe guard, not the seat block it names — and prove nothing.
withStripeSecret();
return [$user, $customer, $instance];
}
it('allows a downgrade when everything fits', function () {
[, $customer, $instance] = downgradeCustomer(seats: 3, usedBytes: 40 * 1024 ** 3);
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
expect($check->allowed)->toBeTrue()->and($check->blockers)->toBe([]);
});
it('names the seats that are in the way, with both numbers', function () {
[, $customer, $instance] = downgradeCustomer(seats: 31);
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
expect($check->allowed)->toBeFalse()
->and($check->blockers[0]['key'])->toBe('seats')
->and($check->blockers[0]['current'])->toBe('31')
->and($check->blockers[0]['limit'])->toBe('10');
});
it('measures storage rather than trusting the allowance', function () {
// 480 GB really in use. The contract says 500 GB, but what decides whether
// 200 GB is enough is what is actually stored.
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: 480 * 1024 ** 3);
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
expect($check->allowed)->toBeFalse()
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']);
});
it('does not refuse a customer who has simply never been measured', function () {
// A new instance has no reading. Refusing on the contractual allowance
// would block every downgrade for anyone who once bought a large plan.
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: null);
expect(DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200])->allowed)->toBeTrue();
});
it('refuses a blocked downgrade even when the button is never rendered', function () {
// The action is callable from a stale tab or a second window. A limit
// enforced only in markup is not enforced.
[$user, $customer, $instance] = downgradeCustomer(seats: 31);
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'downgrade', 'start');
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'downgrade')->count())->toBe(0);
});