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)); // Every running machine answers to a contract, and the allowance is the // contract's — so the fixture has to have one too. App\Models\Subscription::factory()->plan($instance->plan)->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, ]); return [$pve, $customer, $instance->fresh()]; } 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('keeps the allowance the customer bought when a leaner version replaces it', function () { [, , $instance] = trafficSetup(); // The owner replaces the start plan with a version carrying half the // traffic. New customers get 500 GB; this one bought 1000. $catalogue = app(App\Services\Billing\PlanCatalogue::class); $current = $catalogue->currentVersion('start'); $catalogue->schedule($current, $current->available_from, now()); $leaner = App\Models\PlanVersion::query()->create([ ...$current->only(['plan_family_id', 'quota_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), 'version' => 2, 'traffic_gb' => 500, 'features' => $current->features, 'available_from' => now(), ]); $leaner->prices()->create([ 'term' => 'monthly', 'amount_cents' => 4900, 'currency' => App\Models\Subscription::catalogueCurrency(), ]); $leaner->prices()->create([ 'term' => 'yearly', 'amount_cents' => 58800, 'currency' => App\Models\Subscription::catalogueCurrency(), ]); $catalogue->publish($leaner, now()); expect($catalogue->sellable()['start']['traffic_gb'])->toBe(500) ->and(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000); }); 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(); }); it('ignores a sample that carries no counters', function () { [$pve, , $instance] = trafficSetup(); $pve->counters[1042] = ['netin' => 0, 'netout' => 4_000_000]; (new CollectInstanceTraffic)->handle($pve); $pve->counters[1042] = ['netin' => 0, 'netout' => 10_000_000]; (new CollectInstanceTraffic)->handle($pve); expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(6_000_000); // Proxmox answers without telemetry. Treating that as zero would reset the // baseline and count the whole counter again on the next real sample. $blind = new class extends FakeProxmoxClient { public function vmStatus(string $node, int $vmid): array { return ['status' => 'running']; } }; app()->instance(ProxmoxClient::class, $blind); (new CollectInstanceTraffic)->handle($blind); $row = InstanceTraffic::query()->first(); expect($row->tx_bytes)->toBe(6_000_000) ->and($row->last_netout)->toBe(10_000_000); // baseline untouched // The next good sample continues from where it left off. app()->instance(ProxmoxClient::class, $pve); $pve->counters[1042] = ['netin' => 0, 'netout' => 11_000_000]; (new CollectInstanceTraffic)->handle($pve); expect(InstanceTraffic::query()->first()->tx_bytes)->toBe(7_000_000); });