package alert_test import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/alert" ) // ───────────────────────────────────────────────────────────────────────────── // Test helpers // ───────────────────────────────────────────────────────────────────────────── func newTestRedis(t *testing.T) *redis.Client { t.Helper() mr := miniredis.RunT(t) return redis.NewClient(&redis.Options{Addr: mr.Addr()}) } // tgServer is a tiny Telegram Bot API mock. type tgServer struct { statusCode atomic.Int32 // HTTP status to return; default 200 callCount atomic.Int32 // total calls received lastBody atomic.Value // last []byte body received srv *httptest.Server } func newTGServer(t *testing.T) *tgServer { t.Helper() ts := &tgServer{} ts.statusCode.Store(http.StatusOK) ts.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts.callCount.Add(1) body, _ := io.ReadAll(r.Body) ts.lastBody.Store(body) w.WriteHeader(int(ts.statusCode.Load())) w.Write([]byte(`{"ok":true}`)) //nolint:errcheck })) t.Cleanup(ts.srv.Close) return ts } // parsedBody parses the last received request body as a map. func (ts *tgServer) parsedBody(t *testing.T) map[string]string { t.Helper() raw, _ := ts.lastBody.Load().([]byte) if len(raw) == 0 { t.Fatal("tgServer: no body received yet") } var m map[string]string if err := json.Unmarshal(raw, &m); err != nil { t.Fatalf("tgServer: unmarshal body: %v", err) } return m } // newNotifier creates a TGNotifier pointing at the mock server. func newNotifier(t *testing.T, ts *tgServer, rdb *redis.Client) *alert.TGNotifier { t.Helper() return alert.NewTGNotifier(alert.TGConfig{ BotToken: "test-token", ChatID: "-1001234567", RunbookBaseURL: "https://example.internal/runbook", BaseURL: ts.srv.URL, }, rdb, nil) } // ───────────────────────────────────────────────────────────────────────────── // Test: all seven event types render correct templates with runbook anchors // ───────────────────────────────────────────────────────────────────────────── func TestAllEventTypesRendered(t *testing.T) { tests := []struct { eventType alert.EventType wantAnchor string wantTitlePart string // substring expected in the TG message text (HTML-encoded if needed) }{ {alert.EventTypeBlockConfirmed, "#block-confirmed", "判封确认"}, {alert.EventTypeReplenishFailed, "#replenish-failed", "补新连续失败≥3"}, // "<" is HTML-escaped to "<" in TG HTML mode — check escaped form. {alert.EventTypeWatermarkLow, "#watermark-low", "水位<70%"}, {alert.EventTypeBreakerTripped, "#breaker-tripped", "熔断触发"}, {alert.EventTypeProbeAgentLost, "#probe-agent-lost", "探针失联"}, // ">" is HTML-escaped to ">" in TG HTML mode — check escaped form. {alert.EventTypeHeartbeatMissing, "#heartbeat-missing", "心跳缺失>90s"}, {alert.EventTypeFault, "#node-fault", "故障态"}, } for _, tc := range tests { t.Run(string(tc.eventType), func(t *testing.T) { rdb := newTestRedis(t) ts := newTGServer(t) n := newNotifier(t, ts, rdb) e := alert.NewEvent(tc.eventType, "node-abc", map[string]string{ "reason": "test reason", }) ctx := context.Background() if err := n.Notify(ctx, e); err != nil { t.Fatalf("Notify() returned error: %v", err) } // Exactly one TG call must have been made. if got := ts.callCount.Load(); got != 1 { t.Fatalf("TG call count = %d; want 1", got) } body := ts.parsedBody(t) text := body["text"] // Message must contain the Chinese event title. if !strings.Contains(text, tc.wantTitlePart) { t.Errorf("message does not contain %q:\n%s", tc.wantTitlePart, text) } // Message must contain the runbook anchor. if !strings.Contains(text, tc.wantAnchor) { t.Errorf("message does not contain runbook anchor %q:\n%s", tc.wantAnchor, text) } // Message must contain the node ID (internal only — no domain). if !strings.Contains(text, "node-abc") { t.Errorf("message does not contain node ID:\n%s", text) } // parse_mode must be HTML. if body["parse_mode"] != "HTML" { t.Errorf("parse_mode = %q; want HTML", body["parse_mode"]) } }) } } // ───────────────────────────────────────────────────────────────────────────── // Test: dedup — second message for same (Type, NodeID) within 10 min is suppressed // ───────────────────────────────────────────────────────────────────────────── func TestDedupSuppressesSecondMessage(t *testing.T) { rdb := newTestRedis(t) ts := newTGServer(t) n := newNotifier(t, ts, rdb) ctx := context.Background() // Use a Warning-severity event (WatermarkLow) so dedup applies. // Note: WatermarkLow has Warning severity → dedup applies. e := alert.NewEvent(alert.EventTypeBlockConfirmed, "node-dup", nil) // First call — must go through. if err := n.Notify(ctx, e); err != nil { t.Fatalf("first Notify() error: %v", err) } if got := ts.callCount.Load(); got != 1 { t.Fatalf("after first call: TG count = %d; want 1", got) } // Second call with same (Type, NodeID) — must be suppressed (no TG call). if err := n.Notify(ctx, e); err != nil { t.Fatalf("second Notify() error: %v", err) } if got := ts.callCount.Load(); got != 1 { t.Errorf("after second call: TG count = %d; want still 1 (dedup)", got) } // Third call with different NodeID — must go through (distinct dedup key). e2 := alert.NewEvent(alert.EventTypeBlockConfirmed, "node-other", nil) if err := n.Notify(ctx, e2); err != nil { t.Fatalf("third Notify() error: %v", err) } if got := ts.callCount.Load(); got != 2 { t.Errorf("after third call (different node): TG count = %d; want 2", got) } } // ───────────────────────────────────────────────────────────────────────────── // Test: critical events are never deduplicated — each call goes through // ───────────────────────────────────────────────────────────────────────────── func TestCriticalEventsNotDeduped(t *testing.T) { rdb := newTestRedis(t) ts := newTGServer(t) n := newNotifier(t, ts, rdb) ctx := context.Background() // BreakerTripped is Critical severity — must bypass dedup. e := alert.NewEvent(alert.EventTypeBreakerTripped, "node-crit", nil) for i := 1; i <= 3; i++ { if err := n.Notify(ctx, e); err != nil { t.Fatalf("call %d: Notify() error: %v", i, err) } if got := ts.callCount.Load(); int(got) != i { t.Errorf("after call %d: TG count = %d; want %d (no dedup for critical)", i, got, i) } } } // ───────────────────────────────────────────────────────────────────────────── // Test: TG 5xx triggers retries; after exhausting retries falls back to log; // Notify() never returns an error. // ───────────────────────────────────────────────────────────────────────────── func TestTG5xxRetriesThenFallsBack(t *testing.T) { rdb := newTestRedis(t) ts := newTGServer(t) // Capture fallback calls. var fallbackCalls atomic.Int32 fallback := &capturingNotifier{ fn: func(alert.Event) { fallbackCalls.Add(1) }, } n := alert.NewTGNotifier(alert.TGConfig{ BotToken: "test-token", ChatID: "-1001234567", BaseURL: ts.srv.URL, }, rdb, fallback) // Configure mock TG server to return 500. ts.statusCode.Store(http.StatusInternalServerError) ctx := context.Background() e := alert.NewEvent(alert.EventTypeFault, "node-fail", map[string]string{"reason": "both fail"}) // Notify must return nil (not block the scheduler). if err := n.Notify(ctx, e); err != nil { t.Fatalf("Notify() must not return error; got %v", err) } // TG must have been called 3 times (1 initial + 2 retries = maxRetries+1). if got := ts.callCount.Load(); got != 3 { t.Errorf("TG call count = %d; want 3 (1 + 2 retries)", got) } // Fallback must have been called exactly once. if got := fallbackCalls.Load(); got != 1 { t.Errorf("fallback call count = %d; want 1", got) } } // ───────────────────────────────────────────────────────────────────────────── // Test: LogNotifier always succeeds (used in offline/dev mode) // ───────────────────────────────────────────────────────────────────────────── func TestLogNotifier(t *testing.T) { n := alert.LogNotifier{} ctx := context.Background() for _, et := range []alert.EventType{ alert.EventTypeBlockConfirmed, alert.EventTypeReplenishFailed, alert.EventTypeWatermarkLow, alert.EventTypeBreakerTripped, alert.EventTypeProbeAgentLost, alert.EventTypeHeartbeatMissing, alert.EventTypeFault, } { e := alert.NewEvent(et, "node-log", nil) if err := n.Notify(ctx, e); err != nil { t.Errorf("LogNotifier.Notify(%s) error: %v", et, err) } } } // ───────────────────────────────────────────────────────────────────────────── // Test: RunbookAnchor override takes precedence over type default // ───────────────────────────────────────────────────────────────────────────── func TestRunbookAnchorOverride(t *testing.T) { rdb := newTestRedis(t) ts := newTGServer(t) n := newNotifier(t, ts, rdb) e := alert.NewEvent(alert.EventTypeFault, "node-anch", nil) e.RunbookAnchor = "#custom-anchor" if err := n.Notify(context.Background(), e); err != nil { t.Fatal(err) } body := ts.parsedBody(t) if !strings.Contains(body["text"], "#custom-anchor") { t.Errorf("expected #custom-anchor in message:\n%s", body["text"]) } } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── // capturingNotifier is a Notifier that calls fn on each Notify. type capturingNotifier struct { fn func(alert.Event) } func (c *capturingNotifier) Notify(_ context.Context, e alert.Event) error { c.fn(e) return nil }