package metrics import ( "encoding/json" "testing" "time" ) // TestRuleStateJSONRoundTrip pins the true wire shape: `omitempty` on a // time.Time field is a no-op in encoding/json (structs are never "empty"), // so a zero RuleState's since/fired_at keys are always present, carrying // the zero-time string — not omitted. Round-tripping is still lossless for // both a zero and a non-zero RuleState, which is what consumers (the agent // loop's persistence, the console watcher) actually depend on. func TestRuleStateJSONRoundTrip(t *testing.T) { zero := RuleState{Firing: false} b, err := json.Marshal(zero) if err != nil { t.Fatalf("marshal zero RuleState: %v", err) } const wantZero = `{"firing":false,"since":"0001-01-01T00:00:00Z","fired_at":"0001-01-01T00:00:00Z"}` if string(b) != wantZero { t.Fatalf("zero RuleState wire shape changed:\n got: %s\n want: %s", b, wantZero) } var gotZero RuleState if err := json.Unmarshal(b, &gotZero); err != nil { t.Fatalf("unmarshal zero RuleState: %v", err) } if !gotZero.Since.Equal(zero.Since) || !gotZero.FiredAt.Equal(zero.FiredAt) || gotZero != zero { t.Fatalf("zero RuleState did not round-trip: got %+v want %+v", gotZero, zero) } since := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) firedAt := since.Add(10 * time.Minute) nonZero := RuleState{Firing: true, Since: since, FiredAt: firedAt, Value: 0.95} b, err = json.Marshal(nonZero) if err != nil { t.Fatalf("marshal non-zero RuleState: %v", err) } var gotNonZero RuleState if err := json.Unmarshal(b, &gotNonZero); err != nil { t.Fatalf("unmarshal non-zero RuleState: %v", err) } if !gotNonZero.Since.Equal(nonZero.Since) || !gotNonZero.FiredAt.Equal(nonZero.FiredAt) || gotNonZero.Firing != nonZero.Firing || gotNonZero.Value != nonZero.Value { t.Fatalf("non-zero RuleState did not round-trip: got %+v want %+v", gotNonZero, nonZero) } } func TestEvaluateMemSustainAndHysteresis(t *testing.T) { r := DefaultRules() st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} } st, ev := Evaluate(r, st, obs(0.95, t0)) // above, sustain starts — no event yet if len(ev) != 0 || st.Rules["mem"].Firing { t.Fatalf("must not fire before sustain: ev=%v firing=%v", ev, st.Rules["mem"].Firing) } st, ev = Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) // sustain met — fires if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "mem" { t.Fatalf("want fire event, got %v", ev) } st, ev = Evaluate(r, st, obs(0.88, t0.Add(11*time.Minute))) // hysteresis band (0.85..0.90) — stays firing if len(ev) != 0 || !st.Rules["mem"].Firing { t.Fatal("must hold inside hysteresis band") } st, ev = Evaluate(r, st, obs(0.80, t0.Add(12*time.Minute))) // below clear line — clears if len(ev) != 1 || ev[0].Firing { t.Fatalf("want clear event, got %v", ev) } } func TestEvaluateDiskFiresImmediately(t *testing.T) { r := DefaultRules() st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) d := 0.95 _, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d}) if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "disk" { t.Fatalf("disk rule is immediate (sustain 0): want fire on first tick, got %v", ev) } } func TestEvaluateSustainResetsOnDip(t *testing.T) { r := DefaultRules() st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} } st, _ = Evaluate(r, st, obs(0.95, t0)) // sustain starts st, _ = Evaluate(r, st, obs(0.80, t0.Add(5*time.Minute))) // dip below threshold — resets Since if !st.Rules["mem"].Since.IsZero() { t.Fatalf("dip below threshold must reset Since, got %v", st.Rules["mem"].Since) } // Climb back above threshold; Since restarts at t0+10m. If sustain had // wrongly resumed counting from the original t0, this would already have // fired (t0+10m is 10 minutes after t0) — it must not. st, ev := Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) if len(ev) != 0 || st.Rules["mem"].Firing { t.Fatalf("sustain must restart after a dip, not resume from the original Since: ev=%v", ev) } // A full 10 minutes after the restart (t0+20m) — now it fires. _, ev = Evaluate(r, st, obs(0.95, t0.Add(20*time.Minute))) if len(ev) != 1 || !ev[0].Firing { t.Fatalf("want fire once the restarted sustain window elapses, got %v", ev) } } func TestEvaluateNilReadingHoldsState(t *testing.T) { r := DefaultRules() st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) d := 0.95 // Fire disk (immediate). st, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d}) if len(ev) != 1 || !st.Rules["disk"].Firing { t.Fatalf("setup: disk should be firing, got %v %v", ev, st.Rules["disk"]) } quietBefore := st.Rules["mem"] // A tick with nil disk and nil mem must leave both rules exactly as they are: // a firing alert stays firing, a quiet rule stays quiet — no event either way. st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute)}) if len(ev) != 0 { t.Fatalf("nil readings must never emit events, got %v", ev) } if !st.Rules["disk"].Firing { t.Fatal("nil reading must not clear a firing rule") } if st.Rules["mem"] != quietBefore { t.Fatalf("nil reading must not change a quiet rule's state: before=%v after=%v", quietBefore, st.Rules["mem"]) } } func TestEvaluateLagNilForeverNoEvent(t *testing.T) { r := DefaultRules() st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) for i := 0; i < 5; i++ { var ev []Event st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Duration(i) * time.Minute)}) if len(ev) != 0 { t.Fatalf("lag rule must never fire while readings stay nil, got %v", ev) } if st.Rules["lag"].Firing { t.Fatal("lag rule must never fire while readings stay nil") } } } func TestEvaluateLagFiresAndClears(t *testing.T) { r := DefaultRules() st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) lag := func(s int64) *int64 { return &s } st, ev := Evaluate(r, st, Observation{At: t0, LagSecs: lag(1000)}) // > 900, immediate if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "lag" { t.Fatalf("want lag fire on first tick over threshold, got %v", ev) } // Clear line is LagSecs-300 = 600. 700 is inside the hysteresis band (600..900) — stays firing. st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute), LagSecs: lag(700)}) if len(ev) != 0 || !st.Rules["lag"].Firing { t.Fatalf("lag must hold inside its hysteresis band, got ev=%v", ev) } // 500 < 600 — clears. _, ev = Evaluate(r, st, Observation{At: t0.Add(2 * time.Minute), LagSecs: lag(500)}) if len(ev) != 1 || ev[0].Firing { t.Fatalf("want lag clear below LagSecs-300, got %v", ev) } } func TestRulesFromEnv(t *testing.T) { env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } r := RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "95"})) if r.DiskFrac != 0.95 { t.Fatalf("want DiskFrac 0.95, got %v", r.DiskFrac) } def := DefaultRules() r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "nonsense"})) if r.DiskFrac != def.DiskFrac { t.Fatalf("unparsable value must keep default %v, got %v", def.DiskFrac, r.DiskFrac) } r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_MEM_PCT": "150"})) if r.MemFrac != def.MemFrac { t.Fatalf("out-of-range value must keep default %v, got %v", def.MemFrac, r.MemFrac) } r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_CPU_PCT": "80"})) if r.CPUFrac != 0.80 { t.Fatalf("want CPUFrac 0.80, got %v", r.CPUFrac) } } func TestGeoIPRuleFiresAfterItsSustainAndClearsOnZero(t *testing.T) { r := DefaultRules() t0 := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) one, zero := 1.0, 0.0 st := AlertState{V: 1, Box: "b"} st, ev := Evaluate(r, st, Observation{At: t0, GeoIP: &one}) if len(ev) != 0 { t.Fatalf("fired at once: %v", ev) } st, ev = Evaluate(r, st, Observation{At: t0.Add(29 * time.Minute), GeoIP: &one}) if len(ev) != 0 { t.Fatalf("fired before 30 minutes: %v", ev) } st, ev = Evaluate(r, st, Observation{At: t0.Add(30 * time.Minute), GeoIP: &one}) if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "geoip" || ev[0].Value != 1 { t.Fatalf("at 30 minutes: %v", ev) } st, ev = Evaluate(r, st, Observation{At: t0.Add(31 * time.Minute)}) // nil: no change if len(ev) != 0 || !st.Rules["geoip"].Firing { t.Fatalf("a nil reading changed the rule: %v %v", ev, st.Rules["geoip"]) } _, ev = Evaluate(r, st, Observation{At: t0.Add(32 * time.Minute), GeoIP: &zero}) if len(ev) != 1 || ev[0].Firing { t.Fatalf("a zero reading must clear at once: %v", ev) } } // Code review R2-5: a reason that changes while firing re-fires with the // new code, without clearing or restarting the sustain. func TestGeoIPReasonChangeWhileFiring(t *testing.T) { r := DefaultRules() t0 := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) two, three := 2.0, 3.0 st := AlertState{V: 1, Box: "b"} st, _ = Evaluate(r, st, Observation{At: t0, GeoIP: &two}) st, ev := Evaluate(r, st, Observation{At: t0.Add(30 * time.Minute), GeoIP: &two}) if len(ev) != 1 || ev[0].Value != 2 { t.Fatalf("fire = %v", ev) } st, ev = Evaluate(r, st, Observation{At: t0.Add(31 * time.Minute), GeoIP: &three}) if len(ev) != 1 || !ev[0].Firing || ev[0].Value != 3 || !st.Rules["geoip"].Firing || st.Rules["geoip"].Value != 3 { t.Fatalf("escalation = %v %+v", ev, st.Rules["geoip"]) } if _, ev = Evaluate(r, st, Observation{At: t0.Add(32 * time.Minute), GeoIP: &three}); len(ev) != 0 { t.Fatalf("an unchanged reason re-fired: %v", ev) } }