package features import ( "testing" "time" ) func TestDetectSpikesIgnoresChronicPatterns(t *testing.T) { at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12} var events []Event add := func(sig string, n int, when time.Time) { for i := 0; i < n; i++ { events = append(events, Event{Timestamp: when.Add(time.Duration(i) * time.Second), Service: "api", IsError: true, PatternSignature: sig}) } } // chronic poller: 50 errors in every window, including the current one for w := 0; w <= 12; w++ { add("poll-401", 50, at.Add(-time.Duration(w+1)*cfg.Window)) } if got := DetectSpikes(events, at, cfg); len(got) != 0 { t.Fatalf("chronic pattern alone alerted: %+v", got) } // a novel failure appears in the current window add("db-timeout", 20, at.Add(-cfg.Window)) got := DetectSpikes(events, at, cfg) if len(got) != 1 || got[0].Patterns[0].Signature != "db-timeout" || !got[0].Patterns[0].Novel { t.Fatalf("expected db-timeout spike, got %+v", got) } } func TestDetectSpikesRequiresMinimumExcess(t *testing.T) { at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) events := []Event{} for i := 0; i < 3; i++ { events = append(events, Event{Timestamp: at.Add(-time.Minute), Service: "api", IsError: true, PatternSignature: "x"}) } if got := DetectSpikes(events, at, SpikeConfig{}); len(got) != 0 { t.Fatalf("3 errors should stay under the floor: %+v", got) } } func TestBaselineTrimIgnoresEarlierStorm(t *testing.T) { at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 10} var events []Event add := func(sig string, n int, when time.Time) { for i := 0; i < n; i++ { events = append(events, Event{Timestamp: when.Add(time.Duration(i) * time.Millisecond), Service: "api", IsError: true, PatternSignature: sig}) } } // one earlier storm in 1 of 10 baseline windows, then a smaller repeat now add("storm", 1000, at.Add(-6*cfg.Window)) add("storm", 60, at.Add(-cfg.Window)) got := DetectSpikes(events, at, cfg) if len(got) != 1 || got[0].Patterns[0].BaselineMean != 0 { t.Fatalf("storm baseline should be trimmed to 0, got %+v", got) } } func TestSuppressorCooldownAndEscalation(t *testing.T) { at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) spike := func(at time.Time, count int) []Spike { return []Spike{{Service: "api", WindowEnd: at, Patterns: []PatternDelta{{Signature: "x", Count: count, Excess: float64(count)}}}} } s := NewSuppressor(30 * time.Minute) if len(s.Filter(spike(at, 20))) != 1 { t.Fatal("first alert should pass") } if len(s.Filter(spike(at.Add(5*time.Minute), 22))) != 0 { t.Fatal("repeat inside cooldown should be suppressed") } if len(s.Filter(spike(at.Add(10*time.Minute), 45))) != 1 { t.Fatal("2x escalation should re-alert") } if len(s.Filter(spike(at.Add(50*time.Minute), 46))) != 1 { t.Fatal("alert after cooldown should pass") } } func TestDetectSpikesWarmupGuard(t *testing.T) { at := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) cfg := SpikeConfig{Window: 5 * time.Minute, BaselineWindows: 12, WarmupWindows: 6} var events []Event for i := 0; i < 20; i++ { events = append(events, Event{Timestamp: at.Add(-time.Minute).Add(time.Duration(i) * time.Second), Service: "api", IsError: true, PatternSignature: "new"}) } cfg.DataSince = at.Add(-10 * time.Minute) // data began 10 min ago: cold if got := DetectSpikes(events, at, cfg); len(got) != 0 { t.Fatalf("alerted during warm-up: %+v", got) } cfg.DataSince = at.Add(-2 * time.Hour) // warm if got := DetectSpikes(events, at, cfg); len(got) != 1 { t.Fatalf("expected alert once warm, got %+v", got) } cfg.DataSince = time.Time{} // guard disabled if got := DetectSpikes(events, at, cfg); len(got) != 1 { t.Fatalf("guard should be off when DataSince is zero") } }