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'); }); it('lets a throttled instance run again when the new month starts', function () { [$pve, , $instance] = trafficSetup(); // Last month ended with the allowance exhausted and the NIC limited. InstanceTraffic::create([ 'instance_id' => $instance->id, 'period' => now()->subMonth()->format('Y-m'), 'tx_bytes' => 9_999_999_999_999, 'throttled' => true, 'throttled_at' => now()->subDays(3), ]); $pve->networkRates[1042] = 0.25; $pve->counters[1042] = ['netin' => 0, 'netout' => 0]; (new CollectInstanceTraffic)->handle($pve); // The new period starts clean — and the limit must actually come off the // NIC, not just out of the database. expect($pve->networkRates[1042])->toBeNull() ->and(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0); }); it('keeps trying to release a throttle when Proxmox refuses once', function () { [$pve, , $instance] = trafficSetup(); InstanceTraffic::create([ 'instance_id' => $instance->id, 'period' => now()->subMonth()->format('Y-m'), 'tx_bytes' => 9_999_999_999_999, 'throttled' => true, ]); // A hub that fails the first release and works afterwards — the transient // error that would otherwise leave the customer throttled for good. $flaky = new class extends FakeProxmoxClient { public int $attempts = 0; public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void { $this->attempts++; if ($mbytesPerSecond === null && $this->attempts === 1) { throw new RuntimeException('proxmox unavailable'); } parent::setNetworkRate($node, $vmid, $mbytesPerSecond); } }; $flaky->counters[1042] = ['netin' => 0, 'netout' => 0]; app()->instance(ProxmoxClient::class, $flaky); (new CollectInstanceTraffic)->handle($flaky); expect(InstanceTraffic::query()->where('throttled', true)->count())->toBe(1); // Next run: the current row already exists, so a release tied to row // creation would never fire again. (new CollectInstanceTraffic)->handle($flaky); expect(InstanceTraffic::query()->where('throttled', true)->count())->toBe(0) ->and($flaky->networkRates[1042])->toBeNull(); });