'box', 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']); } public function test_a_numeric_breach_opens_a_firing_incident_and_notifies(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $eval->evaluate($server, ['cpu' => 95, 'mem' => 10, 'disk' => 10, 'load' => 1], 'online'); $this->assertDatabaseHas('alert_incidents', ['server_id' => $server->id, 'state' => 'firing', 'value' => 95]); } public function test_firing_a_new_incident_busts_the_sidebar_firing_count_cache(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify'); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); // Warm the badge-count cache while nothing is firing. $this->assertSame(0, AlertIncident::firingCount()); // A breach opens an incident — the sidebar badge must reflect it at once (cache busted), // not stay at the cached 0 until the TTL expires. $eval->evaluate($server, ['cpu' => 95, 'mem' => 10, 'disk' => 10, 'load' => 1], 'online'); $this->assertSame(1, AlertIncident::firingCount()); } public function test_a_sustained_breach_does_not_open_a_second_incident(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once(); // fired once, deduped after AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $eval->evaluate($server, ['cpu' => 95], 'online'); $eval->evaluate($server, ['cpu' => 96], 'online'); // still breached → dedup $this->assertSame(1, AlertIncident::where('state', 'firing')->count()); } public function test_recovery_resolves_the_incident_and_notifies(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false); $notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), true); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $eval->evaluate($server, ['cpu' => 95], 'online'); // fire $eval->evaluate($server, ['cpu' => 20], 'online'); // recover $this->assertSame(0, AlertIncident::where('state', 'firing')->count()); $this->assertDatabaseHas('alert_incidents', ['state' => 'resolved']); } public function test_lt_comparator_fires_below_the_threshold(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once(); AlertRule::create(['name' => 'Disk free low', 'metric' => 'disk', 'comparator' => 'lt', 'threshold' => 10, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $eval->evaluate($server, ['disk' => 5], 'online'); $this->assertDatabaseHas('alert_incidents', ['state' => 'firing', 'value' => 5]); } public function test_offline_rule_fires_when_not_online_and_skips_numeric(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false); AlertRule::create(['name' => 'Down', 'metric' => 'offline', 'scope_type' => 'all', 'enabled' => true]); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); // Offline poll: stale metrics, status offline → only the offline rule fires, cpu is skipped. $eval->evaluate($server, [], 'offline'); $this->assertSame(1, AlertIncident::where('state', 'firing')->count()); $this->assertDatabaseHas('alert_incidents', ['alert_rule_id' => AlertRule::where('metric', 'offline')->first()->id]); } public function test_offline_incident_resolves_when_the_server_comes_back(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->twice(); AlertRule::create(['name' => 'Down', 'metric' => 'offline', 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $eval->evaluate($server, [], 'offline'); // fire $eval->evaluate($server, ['cpu' => 5], 'online'); // back online → resolve $this->assertSame(0, AlertIncident::where('state', 'firing')->count()); } public function test_a_fractional_load_breach_fires_and_is_not_truncated(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once(); // Regression: load 1.5 with threshold 1 used to truncate to `1 > 1` = false and never fire. AlertRule::create(['name' => 'Load', 'metric' => 'load', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => true]); $eval->evaluate($this->server(), ['cpu' => 5, 'load' => 1.5], 'online'); $this->assertDatabaseHas('alert_incidents', ['state' => 'firing', 'value' => 1.5]); } public function test_firing_key_is_a_unique_db_invariant(): void { $rule = AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $key = $rule->id.':'.$server->id; AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 90, 'firing_key' => $key, 'started_at' => now()]); // A concurrent tick opening the same firing incident hits the unique index → no duplicate. $this->expectException(QueryException::class); AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 91, 'firing_key' => $key, 'started_at' => now()]); } public function test_a_resolved_incident_frees_the_firing_key_for_a_re_breach(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->times(3); // fire, resolve, fire again AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]); $server = $this->server(); $eval->evaluate($server, ['cpu' => 95], 'online'); // fire $eval->evaluate($server, ['cpu' => 10], 'online'); // resolve → firing_key nulled $eval->evaluate($server, ['cpu' => 95], 'online'); // re-breach can claim the key again $this->assertSame(1, AlertIncident::where('state', 'firing')->count()); $this->assertSame(2, AlertIncident::count()); // one resolved + one new firing } public function test_a_disabled_rule_never_fires(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->never(); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => false]); $eval->evaluate($this->server(), ['cpu' => 99], 'online'); $this->assertDatabaseCount('alert_incidents', 0); } public function test_scope_server_only_targets_that_server(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->never(); $target = $this->server('10.0.0.1'); $other = $this->server('10.0.0.2'); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 50, 'scope_type' => 'server', 'scope_id' => $target->id, 'enabled' => true]); $eval->evaluate($other, ['cpu' => 99], 'online'); // not the targeted server → nothing $this->assertDatabaseCount('alert_incidents', 0); } public function test_scope_group_targets_only_members(): void { [$eval, $notifier] = $this->make(); $notifier->shouldReceive('notify')->once(); $group = ServerGroup::create(['name' => 'prod', 'color' => 'online']); $member = $this->server('10.0.0.1'); $group->servers()->attach($member->id); AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 50, 'scope_type' => 'group', 'scope_id' => $group->id, 'enabled' => true]); $eval->evaluate($member, ['cpu' => 99], 'online'); $this->assertDatabaseHas('alert_incidents', ['server_id' => $member->id, 'state' => 'firing']); } }