| 1 | package metrics |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // TestRuleStateJSONRoundTrip pins the true wire shape: `omitempty` on a |
| 10 | // time.Time field is a no-op in encoding/json (structs are never "empty"), |
| 11 | // so a zero RuleState's since/fired_at keys are always present, carrying |
| 12 | // the zero-time string — not omitted. Round-tripping is still lossless for |
| 13 | // both a zero and a non-zero RuleState, which is what consumers (the agent |
| 14 | // loop's persistence, the console watcher) actually depend on. |
| 15 | func TestRuleStateJSONRoundTrip(t *testing.T) { |
| 16 | zero := RuleState{Firing: false} |
| 17 | b, err := json.Marshal(zero) |
| 18 | if err != nil { |
| 19 | t.Fatalf("marshal zero RuleState: %v", err) |
| 20 | } |
| 21 | const wantZero = `{"firing":false,"since":"0001-01-01T00:00:00Z","fired_at":"0001-01-01T00:00:00Z"}` |
| 22 | if string(b) != wantZero { |
| 23 | t.Fatalf("zero RuleState wire shape changed:\n got: %s\n want: %s", b, wantZero) |
| 24 | } |
| 25 | var gotZero RuleState |
| 26 | if err := json.Unmarshal(b, &gotZero); err != nil { |
| 27 | t.Fatalf("unmarshal zero RuleState: %v", err) |
| 28 | } |
| 29 | if !gotZero.Since.Equal(zero.Since) || !gotZero.FiredAt.Equal(zero.FiredAt) || gotZero != zero { |
| 30 | t.Fatalf("zero RuleState did not round-trip: got %+v want %+v", gotZero, zero) |
| 31 | } |
| 32 | |
| 33 | since := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 34 | firedAt := since.Add(10 * time.Minute) |
| 35 | nonZero := RuleState{Firing: true, Since: since, FiredAt: firedAt, Value: 0.95} |
| 36 | b, err = json.Marshal(nonZero) |
| 37 | if err != nil { |
| 38 | t.Fatalf("marshal non-zero RuleState: %v", err) |
| 39 | } |
| 40 | var gotNonZero RuleState |
| 41 | if err := json.Unmarshal(b, &gotNonZero); err != nil { |
| 42 | t.Fatalf("unmarshal non-zero RuleState: %v", err) |
| 43 | } |
| 44 | if !gotNonZero.Since.Equal(nonZero.Since) || !gotNonZero.FiredAt.Equal(nonZero.FiredAt) || gotNonZero.Firing != nonZero.Firing || gotNonZero.Value != nonZero.Value { |
| 45 | t.Fatalf("non-zero RuleState did not round-trip: got %+v want %+v", gotNonZero, nonZero) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestEvaluateMemSustainAndHysteresis(t *testing.T) { |
| 50 | r := DefaultRules() |
| 51 | st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| 52 | t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 53 | obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} } |
| 54 | |
| 55 | st, ev := Evaluate(r, st, obs(0.95, t0)) // above, sustain starts — no event yet |
| 56 | if len(ev) != 0 || st.Rules["mem"].Firing { |
| 57 | t.Fatalf("must not fire before sustain: ev=%v firing=%v", ev, st.Rules["mem"].Firing) |
| 58 | } |
| 59 | st, ev = Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) // sustain met — fires |
| 60 | if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "mem" { |
| 61 | t.Fatalf("want fire event, got %v", ev) |
| 62 | } |
| 63 | st, ev = Evaluate(r, st, obs(0.88, t0.Add(11*time.Minute))) // hysteresis band (0.85..0.90) — stays firing |
| 64 | if len(ev) != 0 || !st.Rules["mem"].Firing { |
| 65 | t.Fatal("must hold inside hysteresis band") |
| 66 | } |
| 67 | st, ev = Evaluate(r, st, obs(0.80, t0.Add(12*time.Minute))) // below clear line — clears |
| 68 | if len(ev) != 1 || ev[0].Firing { |
| 69 | t.Fatalf("want clear event, got %v", ev) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestEvaluateDiskFiresImmediately(t *testing.T) { |
| 74 | r := DefaultRules() |
| 75 | st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| 76 | t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 77 | d := 0.95 |
| 78 | |
| 79 | _, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d}) |
| 80 | if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "disk" { |
| 81 | t.Fatalf("disk rule is immediate (sustain 0): want fire on first tick, got %v", ev) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestEvaluateSustainResetsOnDip(t *testing.T) { |
| 86 | r := DefaultRules() |
| 87 | st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| 88 | t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 89 | obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} } |
| 90 | |
| 91 | st, _ = Evaluate(r, st, obs(0.95, t0)) // sustain starts |
| 92 | st, _ = Evaluate(r, st, obs(0.80, t0.Add(5*time.Minute))) // dip below threshold — resets Since |
| 93 | if !st.Rules["mem"].Since.IsZero() { |
| 94 | t.Fatalf("dip below threshold must reset Since, got %v", st.Rules["mem"].Since) |
| 95 | } |
| 96 | // Climb back above threshold; Since restarts at t0+10m. If sustain had |
| 97 | // wrongly resumed counting from the original t0, this would already have |
| 98 | // fired (t0+10m is 10 minutes after t0) — it must not. |
| 99 | st, ev := Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) |
| 100 | if len(ev) != 0 || st.Rules["mem"].Firing { |
| 101 | t.Fatalf("sustain must restart after a dip, not resume from the original Since: ev=%v", ev) |
| 102 | } |
| 103 | // A full 10 minutes after the restart (t0+20m) — now it fires. |
| 104 | _, ev = Evaluate(r, st, obs(0.95, t0.Add(20*time.Minute))) |
| 105 | if len(ev) != 1 || !ev[0].Firing { |
| 106 | t.Fatalf("want fire once the restarted sustain window elapses, got %v", ev) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestEvaluateNilReadingHoldsState(t *testing.T) { |
| 111 | r := DefaultRules() |
| 112 | st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| 113 | t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 114 | d := 0.95 |
| 115 | |
| 116 | // Fire disk (immediate). |
| 117 | st, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d}) |
| 118 | if len(ev) != 1 || !st.Rules["disk"].Firing { |
| 119 | t.Fatalf("setup: disk should be firing, got %v %v", ev, st.Rules["disk"]) |
| 120 | } |
| 121 | quietBefore := st.Rules["mem"] |
| 122 | |
| 123 | // A tick with nil disk and nil mem must leave both rules exactly as they are: |
| 124 | // a firing alert stays firing, a quiet rule stays quiet — no event either way. |
| 125 | st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute)}) |
| 126 | if len(ev) != 0 { |
| 127 | t.Fatalf("nil readings must never emit events, got %v", ev) |
| 128 | } |
| 129 | if !st.Rules["disk"].Firing { |
| 130 | t.Fatal("nil reading must not clear a firing rule") |
| 131 | } |
| 132 | if st.Rules["mem"] != quietBefore { |
| 133 | t.Fatalf("nil reading must not change a quiet rule's state: before=%v after=%v", quietBefore, st.Rules["mem"]) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestEvaluateLagNilForeverNoEvent(t *testing.T) { |
| 138 | r := DefaultRules() |
| 139 | st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| 140 | t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 141 | |
| 142 | for i := 0; i < 5; i++ { |
| 143 | var ev []Event |
| 144 | st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Duration(i) * time.Minute)}) |
| 145 | if len(ev) != 0 { |
| 146 | t.Fatalf("lag rule must never fire while readings stay nil, got %v", ev) |
| 147 | } |
| 148 | if st.Rules["lag"].Firing { |
| 149 | t.Fatal("lag rule must never fire while readings stay nil") |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestEvaluateLagFiresAndClears(t *testing.T) { |
| 155 | r := DefaultRules() |
| 156 | st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| 157 | t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| 158 | lag := func(s int64) *int64 { return &s } |
| 159 | |
| 160 | st, ev := Evaluate(r, st, Observation{At: t0, LagSecs: lag(1000)}) // > 900, immediate |
| 161 | if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "lag" { |
| 162 | t.Fatalf("want lag fire on first tick over threshold, got %v", ev) |
| 163 | } |
| 164 | // Clear line is LagSecs-300 = 600. 700 is inside the hysteresis band (600..900) — stays firing. |
| 165 | st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute), LagSecs: lag(700)}) |
| 166 | if len(ev) != 0 || !st.Rules["lag"].Firing { |
| 167 | t.Fatalf("lag must hold inside its hysteresis band, got ev=%v", ev) |
| 168 | } |
| 169 | // 500 < 600 — clears. |
| 170 | _, ev = Evaluate(r, st, Observation{At: t0.Add(2 * time.Minute), LagSecs: lag(500)}) |
| 171 | if len(ev) != 1 || ev[0].Firing { |
| 172 | t.Fatalf("want lag clear below LagSecs-300, got %v", ev) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestRulesFromEnv(t *testing.T) { |
| 177 | env := func(m map[string]string) func(string) string { |
| 178 | return func(k string) string { return m[k] } |
| 179 | } |
| 180 | |
| 181 | r := RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "95"})) |
| 182 | if r.DiskFrac != 0.95 { |
| 183 | t.Fatalf("want DiskFrac 0.95, got %v", r.DiskFrac) |
| 184 | } |
| 185 | |
| 186 | def := DefaultRules() |
| 187 | r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "nonsense"})) |
| 188 | if r.DiskFrac != def.DiskFrac { |
| 189 | t.Fatalf("unparsable value must keep default %v, got %v", def.DiskFrac, r.DiskFrac) |
| 190 | } |
| 191 | |
| 192 | r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_MEM_PCT": "150"})) |
| 193 | if r.MemFrac != def.MemFrac { |
| 194 | t.Fatalf("out-of-range value must keep default %v, got %v", def.MemFrac, r.MemFrac) |
| 195 | } |
| 196 | |
| 197 | r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_CPU_PCT": "80"})) |
| 198 | if r.CPUFrac != 0.80 { |
| 199 | t.Fatalf("want CPUFrac 0.80, got %v", r.CPUFrac) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestGeoIPRuleFiresAfterItsSustainAndClearsOnZero(t *testing.T) { |
| 204 | r := DefaultRules() |
| 205 | t0 := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 206 | one, zero := 1.0, 0.0 |
| 207 | st := AlertState{V: 1, Box: "b"} |
| 208 | st, ev := Evaluate(r, st, Observation{At: t0, GeoIP: &one}) |
| 209 | if len(ev) != 0 { |
| 210 | t.Fatalf("fired at once: %v", ev) |
| 211 | } |
| 212 | st, ev = Evaluate(r, st, Observation{At: t0.Add(29 * time.Minute), GeoIP: &one}) |
| 213 | if len(ev) != 0 { |
| 214 | t.Fatalf("fired before 30 minutes: %v", ev) |
| 215 | } |
| 216 | st, ev = Evaluate(r, st, Observation{At: t0.Add(30 * time.Minute), GeoIP: &one}) |
| 217 | if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "geoip" || ev[0].Value != 1 { |
| 218 | t.Fatalf("at 30 minutes: %v", ev) |
| 219 | } |
| 220 | st, ev = Evaluate(r, st, Observation{At: t0.Add(31 * time.Minute)}) // nil: no change |
| 221 | if len(ev) != 0 || !st.Rules["geoip"].Firing { |
| 222 | t.Fatalf("a nil reading changed the rule: %v %v", ev, st.Rules["geoip"]) |
| 223 | } |
| 224 | _, ev = Evaluate(r, st, Observation{At: t0.Add(32 * time.Minute), GeoIP: &zero}) |
| 225 | if len(ev) != 1 || ev[0].Firing { |
| 226 | t.Fatalf("a zero reading must clear at once: %v", ev) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // Code review R2-5: a reason that changes while firing re-fires with the |
| 231 | // new code, without clearing or restarting the sustain. |
| 232 | func TestGeoIPReasonChangeWhileFiring(t *testing.T) { |
| 233 | r := DefaultRules() |
| 234 | t0 := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 235 | two, three := 2.0, 3.0 |
| 236 | st := AlertState{V: 1, Box: "b"} |
| 237 | st, _ = Evaluate(r, st, Observation{At: t0, GeoIP: &two}) |
| 238 | st, ev := Evaluate(r, st, Observation{At: t0.Add(30 * time.Minute), GeoIP: &two}) |
| 239 | if len(ev) != 1 || ev[0].Value != 2 { |
| 240 | t.Fatalf("fire = %v", ev) |
| 241 | } |
| 242 | st, ev = Evaluate(r, st, Observation{At: t0.Add(31 * time.Minute), GeoIP: &three}) |
| 243 | if len(ev) != 1 || !ev[0].Firing || ev[0].Value != 3 || !st.Rules["geoip"].Firing || st.Rules["geoip"].Value != 3 { |
| 244 | t.Fatalf("escalation = %v %+v", ev, st.Rules["geoip"]) |
| 245 | } |
| 246 | if _, ev = Evaluate(r, st, Observation{At: t0.Add(32 * time.Minute), GeoIP: &three}); len(ev) != 0 { |
| 247 | t.Fatalf("an unchanged reason re-fired: %v", ev) |
| 248 | } |
| 249 | } |
| 250 | |