Files
pulse/internal/ai/alert_provider_test.go
T

283 lines
8.3 KiB
Go

package ai
import (
"encoding/json"
"strings"
"testing"
"time"
)
type stubAlertProvider struct {
active []AlertInfo
resolved []ResolvedAlertInfo
}
func (s *stubAlertProvider) GetActiveAlerts() []AlertInfo { return s.active }
func (s *stubAlertProvider) GetRecentlyResolved(minutes int) []ResolvedAlertInfo { return s.resolved }
func (s *stubAlertProvider) GetAlertsByResource(resourceID string) []AlertInfo {
out := make([]AlertInfo, 0)
for _, a := range s.active {
if a.ResourceID == resourceID {
out = append(out, a)
}
}
return out
}
func (s *stubAlertProvider) GetAlertHistory(resourceID string, limit int) []ResolvedAlertInfo {
out := make([]ResolvedAlertInfo, 0)
for _, a := range s.resolved {
if a.ResourceID == resourceID {
out = append(out, a)
if len(out) >= limit {
break
}
}
}
return out
}
func TestService_buildAlertContext(t *testing.T) {
now := time.Now()
s := &Service{}
s.SetAlertProvider(&stubAlertProvider{
active: []AlertInfo{
{
ID: "a1",
Type: "cpu",
Level: "critical",
ResourceID: "node:pve1",
ResourceName: "pve1",
ResourceType: "node",
Node: "pve1",
Message: "cpu high",
Value: 95,
Threshold: 80,
StartTime: now.Add(-5 * time.Minute),
Duration: "5 mins",
Acknowledged: true,
},
{
ID: "a2",
Type: "memory",
Level: "warning",
ResourceID: "guest:100",
ResourceName: "vm-100",
ResourceType: "guest",
Message: "mem high",
Value: 80,
Threshold: 75,
StartTime: now.Add(-2 * time.Minute),
Duration: "2 mins",
},
},
resolved: []ResolvedAlertInfo{
{
AlertInfo: AlertInfo{
ID: "r1",
Type: "disk",
Level: "warning",
ResourceID: "storage:local",
ResourceName: "local",
Message: "disk ok",
Duration: "10 mins",
},
ResolvedTime: now.Add(-2 * time.Minute),
Duration: "10 mins",
},
},
})
ctx := s.buildAlertContext()
if !strings.Contains(ctx, "## Alert Status") {
t.Fatalf("expected alert status header, got: %s", ctx)
}
if !strings.Contains(ctx, "### Active Alerts") || !strings.Contains(ctx, "**Critical:**") || !strings.Contains(ctx, "**Warning:**") {
t.Fatalf("expected active alert sections, got: %s", ctx)
}
if !strings.Contains(ctx, "[ACKNOWLEDGED]") || !strings.Contains(ctx, "on node pve1") {
t.Fatalf("expected acknowledged/node formatting, got: %s", ctx)
}
if !strings.Contains(ctx, "### Recently Resolved") {
t.Fatalf("expected recently resolved section, got: %s", ctx)
}
}
func TestService_buildAlertContext_Empty(t *testing.T) {
s := &Service{}
if got := s.buildAlertContext(); got != "" {
t.Fatalf("expected empty string, got: %q", got)
}
s.SetAlertProvider(&stubAlertProvider{})
if got := s.buildAlertContext(); got != "" {
t.Fatalf("expected empty string when no alerts, got: %q", got)
}
}
func TestService_buildAlertContext_NoActiveWithResolved(t *testing.T) {
now := time.Now()
resolved := make([]ResolvedAlertInfo, 0, 6)
for i := 0; i < 6; i++ {
resolved = append(resolved, ResolvedAlertInfo{
AlertInfo: AlertInfo{
ID: "r" + string(rune('a'+i)),
Type: "disk",
Level: "warning",
ResourceID: "storage:local",
ResourceName: "local",
Message: "disk ok",
Duration: "10 mins",
},
ResolvedTime: now.Add(-time.Duration(i) * time.Minute),
Duration: "10 mins",
})
}
s := &Service{}
s.SetAlertProvider(&stubAlertProvider{
active: nil,
resolved: resolved,
})
ctx := s.buildAlertContext()
if !strings.Contains(ctx, "No Active Alerts") {
t.Fatalf("expected no active alerts section, got: %s", ctx)
}
if !strings.Contains(ctx, "Recently Resolved") {
t.Fatalf("expected recently resolved section, got: %s", ctx)
}
if !strings.Contains(ctx, "... and 1 more") {
t.Fatalf("expected overflow line for resolved alerts, got: %s", ctx)
}
}
func TestService_buildTargetAlertContext(t *testing.T) {
s := &Service{}
s.SetAlertProvider(&stubAlertProvider{
active: []AlertInfo{
{ID: "a1", Level: "critical", Type: "cpu", ResourceID: "node:pve1", ResourceName: "pve1", Duration: "1 min", Value: 90, Threshold: 80},
{ID: "a2", Level: "warning", Type: "memory", ResourceID: "node:pve2", ResourceName: "pve2", Duration: "1 min", Value: 80, Threshold: 70},
},
})
got := s.buildTargetAlertContext("node:pve1")
if !strings.Contains(got, "Active Alerts for This Resource") || !strings.Contains(got, "pve1") {
t.Fatalf("unexpected context: %s", got)
}
if strings.Contains(got, "pve2") {
t.Fatalf("unexpected extra resource: %s", got)
}
}
func TestService_buildTargetAlertContext_Empty(t *testing.T) {
s := &Service{}
if got := s.buildTargetAlertContext("node:pve1"); got != "" {
t.Fatalf("expected empty string with no provider, got: %q", got)
}
s.SetAlertProvider(&stubAlertProvider{
active: []AlertInfo{
{ID: "a1", Level: "critical", Type: "cpu", ResourceID: "node:pve1", ResourceName: "pve1"},
},
})
if got := s.buildTargetAlertContext(""); got != "" {
t.Fatalf("expected empty string for empty resource ID, got: %q", got)
}
if got := s.buildTargetAlertContext("node:missing"); got != "" {
t.Fatalf("expected empty string for missing resource, got: %q", got)
}
}
func TestFormatTimeAgo(t *testing.T) {
now := time.Now()
if got := formatTimeAgo(now.Add(-10 * time.Second)); got != "just now" {
t.Fatalf("formatTimeAgo(<1m) = %q", got)
}
if got := formatTimeAgo(now.Add(-1 * time.Minute)); got != "1 minute" {
t.Fatalf("formatTimeAgo(1m) = %q", got)
}
if got := formatTimeAgo(now.Add(-2 * time.Minute)); got != "2 minutes" {
t.Fatalf("formatTimeAgo(2m) = %q", got)
}
if got := formatTimeAgo(now.Add(-1 * time.Hour)); got != "1 hour" {
t.Fatalf("formatTimeAgo(1h) = %q", got)
}
if got := formatTimeAgo(now.Add(-2 * time.Hour)); got != "2 hours" {
t.Fatalf("formatTimeAgo(2h) = %q", got)
}
if got := formatTimeAgo(now.Add(-24 * time.Hour)); got != "1 day" {
t.Fatalf("formatTimeAgo(1d) = %q", got)
}
if got := formatTimeAgo(now.Add(-48 * time.Hour)); got != "2 days" {
t.Fatalf("formatTimeAgo(2d) = %q", got)
}
}
func TestGenerateAlertInvestigationPrompt(t *testing.T) {
out := GenerateAlertInvestigationPrompt(AlertInvestigationRequest{
Level: "critical",
ResourceName: "pve1",
ResourceType: "node",
AlertType: "cpu",
Value: 95,
Threshold: 80,
Duration: "5 mins",
Node: "pve1",
})
if !strings.Contains(out, "Investigate this CRITICAL alert") ||
!strings.Contains(out, "**Resource:** pve1 (node)") ||
!strings.Contains(out, "**Node:** pve1") {
t.Fatalf("unexpected prompt: %s", out)
}
if !strings.Contains(out, "Ask for operator approval before running any diagnostic command") {
t.Fatalf("expected approval-bound diagnostic guidance, got: %s", out)
}
if strings.Contains(out, "execute diagnostic commands") {
t.Fatalf("prompt must not instruct autonomous diagnostic command execution: %s", out)
}
}
func TestAlertInvestigationRequestJSONCanonicalOutput(t *testing.T) {
req := AlertInvestigationRequest{
AlertIdentifier: "instance:node:100::metric/cpu",
ResourceID: "agent-1",
ResourceName: "node-1",
ResourceType: "node",
AlertType: "cpu",
Level: "warning",
Message: "high cpu",
}
raw, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["alertIdentifier"] != "instance:node:100::metric/cpu" {
t.Fatalf("expected canonical alertIdentifier, got %#v", payload["alertIdentifier"])
}
if _, ok := payload["alert_id"]; ok {
t.Fatalf("did not expect legacy alert_id in canonical payload, got %#v", payload["alert_id"])
}
var decoded AlertInvestigationRequest
if err := json.Unmarshal([]byte(`{
"alertIdentifier":"instance:node:100::metric/cpu",
"resource_id":"agent-1",
"resource_name":"node-1",
"resource_type":"node",
"alert_type":"cpu",
"level":"warning",
"message":"high cpu"
}`), &decoded); err != nil {
t.Fatalf("unmarshal canonical request: %v", err)
}
if decoded.AlertIdentifier != "instance:node:100::metric/cpu" {
t.Fatalf("expected canonical alertIdentifier to load, got %q", decoded.AlertIdentifier)
}
}