CluPilotCloud/tests/Feature/TrafficTest.php

160 lines
6.2 KiB
PHP

<?php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceTraffic;
use App\Models\User;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;
function trafficSetup(array $overrides = []): array
{
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
$host = App\Models\Host::factory()->active()->create();
$instance = Instance::factory()->create(array_merge([
'customer_id' => $customer->id,
'host_id' => $host->id,
'vmid' => 1042,
'plan' => 'start', // 1000 GB
'status' => 'active',
], $overrides));
return [$pve, $customer, $instance];
}
it('counts the difference between samples, not the raw counter', function () {
[$pve, , $instance] = trafficSetup();
// First sample only establishes the baseline: what the VM did before we
// started counting this month is not the customer's bill.
$pve->counters[1042] = ['netin' => 5_000_000, 'netout' => 9_000_000];
(new CollectInstanceTraffic)->handle($pve);
$row = InstanceTraffic::query()->firstOrFail();
expect($row->tx_bytes)->toBe(0)
->and($row->last_netout)->toBe(9_000_000);
$pve->counters[1042] = ['netin' => 6_000_000, 'netout' => 11_500_000];
(new CollectInstanceTraffic)->handle($pve);
expect($row->fresh()->tx_bytes)->toBe(2_500_000)
->and($row->fresh()->rx_bytes)->toBe(1_000_000);
});
it('treats a counter that went backwards as a restart, not a refund', function () {
[$pve, , $instance] = trafficSetup();
$pve->counters[1042] = ['netin' => 0, 'netout' => 8_000_000];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => 12_000_000];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_000_000);
// VM restarted: Proxmox counts from zero again.
$pve->counters[1042] = ['netin' => 0, 'netout' => 500_000];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(4_500_000);
});
it('throttles instead of cutting the service off when the allowance is gone', function () {
[$pve, , $instance] = trafficSetup();
$quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1];
(new CollectInstanceTraffic)->handle($pve);
$row = InstanceTraffic::query()->firstOrFail();
expect($row->throttled)->toBeTrue()
// Slowed down, never stopped: a dead Nextcloud gets a cancellation,
// a slow one gets a top-up.
->and($pve->networkRates[1042])->toBeGreaterThan(0)
->and(TrafficMeter::for($instance->fresh())->state())->toBe('exhausted');
});
it('gives the line back when the customer buys more', function () {
[$pve, , $instance] = trafficSetup();
$quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => $quota + 1];
(new CollectInstanceTraffic)->handle($pve);
expect($pve->networkRates[1042])->not->toBeNull();
$instance->update(['traffic_addons' => 1]); // one extra pack
(new CollectInstanceTraffic)->handle($pve);
expect($pve->networkRates[1042])->toBeNull()
->and(InstanceTraffic::query()->first()->throttled)->toBeFalse();
});
it('warns once per threshold, not on every sample', function () {
[$pve, , $instance] = trafficSetup();
$quota = TrafficMeter::quotaGb($instance) * 1000 ** 3;
$pve->counters[1042] = ['netin' => 0, 'netout' => 0];
(new CollectInstanceTraffic)->handle($pve);
$pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.82)];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80);
// Still in the same band a sample later — nothing new to say.
$pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.84)];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->notified_percent)->toBe(80);
// Crossing the next one does warn again.
$pve->counters[1042] = ['netin' => 0, 'netout' => (int) ($quota * 0.96)];
(new CollectInstanceTraffic)->handle($pve);
expect(InstanceTraffic::query()->first()->notified_percent)->toBe(95);
});
it('counts add-on packs towards the allowance', function () {
[, , $instance] = trafficSetup();
expect(TrafficMeter::quotaGb($instance))->toBe(1000);
$instance->update(['traffic_addons' => 2]);
expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000);
});
it('shows the customer what is left, and offers a top-up when it gets tight', function () {
[, $customer, $instance] = trafficSetup();
$user = User::factory()->create(['email' => $customer->email]);
InstanceTraffic::create([
'instance_id' => $instance->id,
'period' => InstanceTraffic::currentPeriod(),
'tx_bytes' => (int) (1000 * 1000 ** 3 * 0.10),
]);
// Comfortable: no upsell in the customer's face.
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.traffic.title'))
->assertDontSee(__('dashboard.traffic.buy'));
InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.traffic.buy'));
});
it('shows allowances in the units they are sold in', function () {
// A 1000 GB plan must read as 1 TB, not as 931 GB: quotas are computed in
// SI units, so displaying binary ones made the two disagree on screen.
expect(App\Support\Bytes::human(1000 * 1000 ** 3))->toBe('1 TB')
->and(App\Support\Bytes::human(880 * 1000 ** 3))->toBe('880 GB')
->and(App\Support\Bytes::human(1_500_000))->toBe('1,5 MB')
->and(App\Support\Bytes::human(512))->toBe('512 B');
});