'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); } /** A freshly-polled server (DB row updated by the poller) is sampled WITHOUT any cache — the * history no longer depends on a cross-container cache, so it works whenever the live gauges do. */ public function test_sample_metrics_persists_a_row_from_the_polled_db_row_without_a_cache(): void { $s = $this->server(); $s->forceFill(['cpu' => 40, 'mem' => 55, 'disk' => 12, 'last_seen_at' => now()])->save(); // deliberately NO Cache::put — the DB row is the source of truth. $this->artisan('clusev:sample-metrics')->assertSuccessful(); $this->assertDatabaseHas('metric_samples', ['server_id' => $s->id, 'cpu' => 40, 'mem' => 55, 'disk' => 12]); $this->assertSame(1, MetricSample::count()); } /** load is not on the server row; it is taken from the live cache when present (best-effort). */ public function test_sample_metrics_reads_load_from_the_cache_when_present(): void { $s = $this->server(); $s->forceFill(['cpu' => 10, 'mem' => 20, 'disk' => 30, 'last_seen_at' => now()])->save(); Cache::put("metrics:latest:{$s->id}", ['cpu' => 10, 'mem' => 20, 'disk' => 30, 'load' => 1.25], now()->addMinutes(5)); $this->artisan('clusev:sample-metrics')->assertSuccessful(); $this->assertSame(1.25, (float) MetricSample::first()->load); } public function test_sample_metrics_clamps_percentages_and_skips_stale_or_unpolled_servers(): void { $polled = $this->server(); $polled->forceFill(['cpu' => 250, 'mem' => 60, 'disk' => 10, 'last_seen_at' => now()])->save(); // never polled (last_seen_at null) → no sample $this->server(); // polled long ago (stale) → no sample (an honest gap while offline) $this->server()->forceFill(['cpu' => 5, 'mem' => 5, 'disk' => 5, 'last_seen_at' => now()->subMinutes(10)])->save(); $this->artisan('clusev:sample-metrics')->assertSuccessful(); $this->assertSame(1, MetricSample::count()); $this->assertSame(100, MetricSample::first()->cpu); // 250 clamped to 100 $this->assertSame($polled->id, MetricSample::first()->server_id); } public function test_sample_metrics_prunes_beyond_the_retention_window(): void { $s = $this->server(); MetricSample::create(['server_id' => $s->id, 'cpu' => 1, 'mem' => 1, 'disk' => 1, 'load' => 0, 'sampled_at' => now()->subDays(40)]); MetricSample::create(['server_id' => $s->id, 'cpu' => 2, 'mem' => 2, 'disk' => 2, 'load' => 0, 'sampled_at' => now()->subDays(5)]); $this->artisan('clusev:sample-metrics', ['--retention' => 30])->assertSuccessful(); $this->assertDatabaseMissing('metric_samples', ['cpu' => 1]); // 40d old → pruned $this->assertDatabaseHas('metric_samples', ['cpu' => 2]); // 5d old → kept } public function test_series_averages_samples_into_non_empty_buckets(): void { $s = $this->server(); MetricSample::create(['server_id' => $s->id, 'cpu' => 20, 'mem' => 40, 'disk' => 10, 'load' => 0.5, 'sampled_at' => now()->subSeconds(90)]); MetricSample::create(['server_id' => $s->id, 'cpu' => 40, 'mem' => 60, 'disk' => 10, 'load' => 1.5, 'sampled_at' => now()->subSeconds(80)]); $series = app(MetricHistory::class)->series($s, 300); $this->assertSame(300, $series['window']); foreach (['from', 'now', 'bucket'] as $k) { $this->assertArrayHasKey($k, $series); } $this->assertNotEmpty($series['points']); // both samples land in the same bucket → averaged (20 + 40) / 2 = 30 $this->assertSame(30, $series['points'][0]['cpu']); // only non-empty buckets are returned — no null gaps (the client connects them continuously) foreach ($series['points'] as $p) { $this->assertIsInt($p['cpu']); } } public function test_history_route_returns_the_series_json(): void { $this->actingAs(User::factory()->create(['must_change_password' => false])); $s = $this->server(); MetricSample::create(['server_id' => $s->id, 'cpu' => 33, 'mem' => 44, 'disk' => 11, 'load' => 0.4, 'sampled_at' => now()->subMinutes(5)]); $this->getJson(route('servers.history', ['server' => $s, 'range' => '24h'])) ->assertOk() ->assertJsonPath('window', 86400) ->assertJsonStructure(['window', 'from', 'now', 'bucket', 'points' => [['t', 'cpu', 'mem', 'disk', 'load']]]); } public function test_history_route_clamps_an_unknown_range(): void { $this->actingAs(User::factory()->create(['must_change_password' => false])); $this->getJson(route('servers.history', ['server' => $this->server(), 'range' => 'evil'])) ->assertOk() ->assertJsonPath('window', 86400); // unknown → default 24h } }