mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-09 10:57:04 +00:00
904 lines
26 KiB
Go
904 lines
26 KiB
Go
package memory
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
func TestIncidentJSONCanonicalOutput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
incident := Incident{
|
|
ID: "incident-1",
|
|
AlertIdentifier: "instance:node:100::metric/cpu",
|
|
AlertType: "cpu",
|
|
Level: "warning",
|
|
ResourceID: "resource-1",
|
|
ResourceName: "resource-1",
|
|
Status: IncidentStatusOpen,
|
|
OpenedAt: now,
|
|
}
|
|
|
|
data, err := json.Marshal(incident)
|
|
if err != nil {
|
|
t.Fatalf("marshal incident: %v", err)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(data, &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["alertId"]; ok {
|
|
t.Fatalf("did not expect alertId in canonical payload, got %#v", payload["alertId"])
|
|
}
|
|
|
|
var decoded Incident
|
|
if err := json.Unmarshal([]byte(`{
|
|
"id":"incident-2",
|
|
"alertIdentifier":"instance:node:100::metric/cpu",
|
|
"alertType":"cpu",
|
|
"level":"warning",
|
|
"resourceId":"resource-2",
|
|
"resourceName":"resource-2",
|
|
"status":"open",
|
|
"openedAt":"2026-03-01T00:00:00Z"
|
|
}`), &decoded); err != nil {
|
|
t.Fatalf("decode canonical incident: %v", err)
|
|
}
|
|
if decoded.AlertIdentifier != "instance:node:100::metric/cpu" {
|
|
t.Fatalf("expected canonical alertIdentifier to load, got %q", decoded.AlertIdentifier)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordTimeline(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 10,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-1",
|
|
ResourceName: "vm-1",
|
|
StartTime: time.Now().Add(-5 * time.Minute),
|
|
Value: 92,
|
|
Threshold: 85,
|
|
}
|
|
|
|
store.RecordAlertFired(alert)
|
|
store.RecordAlertAcknowledged(alert, "admin")
|
|
store.RecordAnalysis(alert.ID, "analysis complete", map[string]interface{}{
|
|
"findings": 1,
|
|
})
|
|
store.RecordCommand(alert.ID, "systemctl restart nginx", true, "ok", nil)
|
|
store.RecordAlertResolved(alert, time.Now())
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatalf("expected timeline, got nil")
|
|
}
|
|
if timeline.Status != IncidentStatusResolved {
|
|
t.Fatalf("expected status %q, got %q", IncidentStatusResolved, timeline.Status)
|
|
}
|
|
if timeline.AckUser != "admin" {
|
|
t.Fatalf("expected ack user admin, got %q", timeline.AckUser)
|
|
}
|
|
if len(timeline.Events) < 4 {
|
|
t.Fatalf("expected events recorded, got %d", len(timeline.Events))
|
|
}
|
|
|
|
if ok := store.RecordNote(alert.ID, "", "note text", ""); !ok {
|
|
t.Fatalf("expected note to be saved")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_ProjectsCanonicalTimelineWhenAttached(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 20,
|
|
MaxAgeDays: 30,
|
|
})
|
|
canonicalStore := unifiedresources.NewMemoryStore()
|
|
store.SetResourceTimelineStore(canonicalStore)
|
|
|
|
alertStartedAt := time.Now().UTC().Add(-15 * time.Minute).Truncate(time.Second)
|
|
alert := &alerts.Alert{
|
|
ID: "alert-projected-1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "res-projected-1",
|
|
ResourceName: "vm-projected-1",
|
|
StartTime: alertStartedAt,
|
|
Value: 91,
|
|
Threshold: 80,
|
|
}
|
|
|
|
store.RecordAlertFired(alert)
|
|
store.RecordAnalysis(alert.ID, "Pulse Patrol analysis completed", nil)
|
|
|
|
fired := unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertFired, alert.StartTime, "", unifiedresources.AlertTimelineChange{
|
|
AlertIdentifier: alert.ID,
|
|
AlertType: alert.Type,
|
|
AlertLevel: string(alert.Level),
|
|
AlertMessage: alert.Message,
|
|
AlertValue: alert.Value,
|
|
AlertThreshold: alert.Threshold,
|
|
})
|
|
runbook := unifiedresources.BuildRunbookExecutionChange(alert.ResourceID, alert.ID, "agent:pulse-patrol", "rb-1", "Restart service", "resolved", true, "Recovered", nil)
|
|
resolvedAt := alert.StartTime.Add(5 * time.Minute)
|
|
resolved := unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertResolved, resolvedAt, "", unifiedresources.AlertTimelineChange{
|
|
AlertIdentifier: alert.ID,
|
|
AlertType: alert.Type,
|
|
AlertLevel: string(alert.Level),
|
|
AlertMessage: "CPU normalized",
|
|
})
|
|
for _, change := range []*unifiedresources.ResourceChange{fired, runbook, resolved} {
|
|
if err := canonicalStore.RecordChange(*change); err != nil {
|
|
t.Fatalf("RecordChange(%s): %v", change.ID, err)
|
|
}
|
|
}
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatal("expected projected timeline")
|
|
}
|
|
if timeline.Status != IncidentStatusResolved {
|
|
t.Fatalf("Status = %q, want %q", timeline.Status, IncidentStatusResolved)
|
|
}
|
|
|
|
foundAnalysis := false
|
|
foundRunbook := false
|
|
for _, event := range timeline.Events {
|
|
switch event.Type {
|
|
case IncidentEventAnalysis:
|
|
foundAnalysis = true
|
|
case IncidentEventRunbook:
|
|
foundRunbook = true
|
|
}
|
|
}
|
|
if !foundAnalysis {
|
|
t.Fatal("expected analysis event to remain in projected timeline")
|
|
}
|
|
if !foundRunbook {
|
|
t.Fatal("expected runbook event to be projected from canonical history")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_CanonicalProjectionOverridesLegacyLifecycleState(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 20,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
alertStartedAt := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second)
|
|
alert := &alerts.Alert{
|
|
ID: "alert-projected-legacy-1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "res-projected-legacy-1",
|
|
ResourceName: "vm-projected-legacy-1",
|
|
StartTime: alertStartedAt,
|
|
Value: 96,
|
|
Threshold: 80,
|
|
}
|
|
|
|
store.RecordAlertFired(alert)
|
|
store.RecordAlertAcknowledged(alert, "legacy-user")
|
|
store.RecordRunbook(alert.ID, "rb-legacy", "Restart service", "resolved", true, "Recovered")
|
|
store.RecordAlertResolved(alert, alertStartedAt.Add(2*time.Minute))
|
|
store.RecordAnalysis(alert.ID, "Canonical projection should keep this annotation", nil)
|
|
|
|
canonicalStore := unifiedresources.NewMemoryStore()
|
|
store.SetResourceTimelineStore(canonicalStore)
|
|
|
|
fired := unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertFired, alert.StartTime, "", unifiedresources.AlertTimelineChange{
|
|
AlertIdentifier: alert.ID,
|
|
AlertType: alert.Type,
|
|
AlertLevel: string(alert.Level),
|
|
AlertValue: alert.Value,
|
|
AlertThreshold: alert.Threshold,
|
|
})
|
|
if err := canonicalStore.RecordChange(*fired); err != nil {
|
|
t.Fatalf("RecordChange(%s): %v", fired.ID, err)
|
|
}
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatal("expected projected timeline")
|
|
}
|
|
if timeline.Status != IncidentStatusOpen {
|
|
t.Fatalf("Status = %q, want %q", timeline.Status, IncidentStatusOpen)
|
|
}
|
|
if timeline.Acknowledged {
|
|
t.Fatal("expected canonical projection to clear legacy acknowledgement state")
|
|
}
|
|
if timeline.ClosedAt != nil {
|
|
t.Fatal("expected canonical projection to clear legacy resolved state")
|
|
}
|
|
|
|
foundAnalysis := false
|
|
for _, event := range timeline.Events {
|
|
switch event.Type {
|
|
case IncidentEventAnalysis:
|
|
foundAnalysis = true
|
|
case IncidentEventAlertAcknowledged, IncidentEventAlertResolved, IncidentEventRunbook:
|
|
t.Fatalf("unexpected legacy projected event type %q in canonical timeline", event.Type)
|
|
}
|
|
}
|
|
if !foundAnalysis {
|
|
t.Fatal("expected annotation events to remain in projected timeline")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_CanonicalModeKeepsShellAsOccurrenceAndAnnotationsOnly(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 20,
|
|
MaxAgeDays: 30,
|
|
})
|
|
store.SetResourceTimelineStore(unifiedresources.NewMemoryStore())
|
|
|
|
firstStart := time.Now().UTC().Add(-25 * time.Minute).Truncate(time.Second)
|
|
first := &alerts.Alert{
|
|
ID: "alert-shell-1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "res-shell-1",
|
|
ResourceName: "vm-shell-1",
|
|
StartTime: firstStart,
|
|
}
|
|
|
|
store.RecordAlertFired(first)
|
|
store.RecordAlertAcknowledged(first, "operator")
|
|
store.RecordRunbook(first.ID, "rb-1", "Restart service", "resolved", true, "Recovered")
|
|
store.RecordAlertResolved(first, firstStart.Add(4*time.Minute))
|
|
|
|
if len(store.incidents) != 1 {
|
|
t.Fatalf("expected one shell incident, got %d", len(store.incidents))
|
|
}
|
|
firstShell := store.incidents[0]
|
|
if firstShell.OccurrenceClosedAt == nil {
|
|
t.Fatal("expected canonical shell to preserve private occurrence closure boundary")
|
|
}
|
|
for _, event := range firstShell.Events {
|
|
switch event.Type {
|
|
case IncidentEventAlertAcknowledged, IncidentEventAlertResolved, IncidentEventRunbook:
|
|
t.Fatalf("unexpected derived event %q stored in canonical incident shell", event.Type)
|
|
}
|
|
}
|
|
|
|
second := &alerts.Alert{
|
|
ID: first.ID,
|
|
Type: first.Type,
|
|
Level: first.Level,
|
|
ResourceID: first.ResourceID,
|
|
ResourceName: first.ResourceName,
|
|
StartTime: firstStart.Add(10 * time.Minute),
|
|
}
|
|
store.RecordAlertFired(second)
|
|
|
|
if len(store.incidents) != 2 {
|
|
t.Fatalf("expected second alert occurrence to create a new shell, got %d incidents", len(store.incidents))
|
|
}
|
|
if store.incidents[1].OccurrenceClosedAt != nil {
|
|
t.Fatal("expected new occurrence shell to remain open")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_GetTimelineByAlertAt(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 10,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
base := time.Now().UTC()
|
|
|
|
first := &alerts.Alert{
|
|
ID: "alert-2",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-2",
|
|
ResourceName: "vm-2",
|
|
StartTime: base.Add(-2 * time.Hour),
|
|
}
|
|
|
|
second := &alerts.Alert{
|
|
ID: "alert-2",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-2",
|
|
ResourceName: "vm-2",
|
|
StartTime: base.Add(-10 * time.Minute),
|
|
}
|
|
|
|
store.RecordAlertFired(first)
|
|
store.RecordAlertResolved(first, base.Add(-90*time.Minute))
|
|
store.RecordAlertFired(second)
|
|
|
|
timeline := store.GetTimelineByAlertAt(first.ID, first.StartTime)
|
|
if timeline == nil {
|
|
t.Fatalf("expected timeline for first incident, got nil")
|
|
}
|
|
if !timeline.OpenedAt.Equal(first.StartTime) {
|
|
t.Fatalf("expected openedAt %s, got %s", first.StartTime, timeline.OpenedAt)
|
|
}
|
|
|
|
timeline = store.GetTimelineByAlertAt(second.ID, second.StartTime)
|
|
if timeline == nil {
|
|
t.Fatalf("expected timeline for second incident, got nil")
|
|
}
|
|
if !timeline.OpenedAt.Equal(second.StartTime) {
|
|
t.Fatalf("expected openedAt %s, got %s", second.StartTime, timeline.OpenedAt)
|
|
}
|
|
|
|
timeline = store.GetTimelineByAlertAt(second.ID, base.Add(-45*time.Minute))
|
|
if timeline != nil {
|
|
t.Fatalf("expected no timeline for mismatched start time")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordAlertUnacknowledged(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 10,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-unack-1",
|
|
Type: "memory",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-3",
|
|
ResourceName: "vm-3",
|
|
StartTime: time.Now().Add(-10 * time.Minute),
|
|
}
|
|
|
|
// Fire alert and acknowledge it
|
|
store.RecordAlertFired(alert)
|
|
store.RecordAlertAcknowledged(alert, "admin")
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatalf("expected timeline after ack")
|
|
}
|
|
if !timeline.Acknowledged {
|
|
t.Fatal("expected acknowledged=true after acknowledgement")
|
|
}
|
|
if timeline.AckUser != "admin" {
|
|
t.Errorf("expected ack user admin, got %q", timeline.AckUser)
|
|
}
|
|
|
|
// Now unacknowledge
|
|
store.RecordAlertUnacknowledged(alert, "operator")
|
|
|
|
timeline = store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatalf("expected timeline after unack")
|
|
}
|
|
if timeline.Acknowledged {
|
|
t.Fatal("expected acknowledged=false after unacknowledgement")
|
|
}
|
|
if timeline.AckUser != "" {
|
|
t.Errorf("expected empty ack user after unack, got %q", timeline.AckUser)
|
|
}
|
|
if timeline.AckTime != nil {
|
|
t.Error("expected nil ack time after unack")
|
|
}
|
|
|
|
// Check for the unacknowledge event
|
|
foundUnackEvent := false
|
|
for _, evt := range timeline.Events {
|
|
if evt.Type == IncidentEventAlertUnacknowledged {
|
|
foundUnackEvent = true
|
|
if user, ok := evt.Details["user"].(string); !ok || user != "operator" {
|
|
t.Errorf("expected user 'operator' in event details, got %v", evt.Details["user"])
|
|
}
|
|
}
|
|
}
|
|
if !foundUnackEvent {
|
|
t.Error("expected to find unacknowledge event in timeline")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordAlertUnacknowledged_NilAlert(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
// Should not panic with nil alert
|
|
store.RecordAlertUnacknowledged(nil, "admin")
|
|
}
|
|
|
|
func TestIncidentStore_RecordRunbook(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 10,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-runbook-1",
|
|
Type: "disk",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "res-4",
|
|
ResourceName: "storage-1",
|
|
StartTime: time.Now().Add(-5 * time.Minute),
|
|
}
|
|
|
|
store.RecordAlertFired(alert)
|
|
|
|
// Record a runbook execution
|
|
store.RecordRunbook(alert.ID, "runbook-cleanup", "Disk Cleanup", "success", true, "Freed 10GB")
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatal("expected timeline after runbook")
|
|
}
|
|
|
|
// Find the runbook event
|
|
foundRunbookEvent := false
|
|
for _, evt := range timeline.Events {
|
|
if evt.Type == IncidentEventRunbook {
|
|
foundRunbookEvent = true
|
|
if !strings.Contains(evt.Summary, "Disk Cleanup") {
|
|
t.Errorf("expected summary to contain 'Disk Cleanup', got %q", evt.Summary)
|
|
}
|
|
if !strings.Contains(evt.Summary, "success") {
|
|
t.Errorf("expected summary to contain 'success', got %q", evt.Summary)
|
|
}
|
|
if runbookID, ok := evt.Details["runbook_id"].(string); !ok || runbookID != "runbook-cleanup" {
|
|
t.Errorf("expected runbook_id 'runbook-cleanup', got %v", evt.Details["runbook_id"])
|
|
}
|
|
if automatic, ok := evt.Details["automatic"].(bool); !ok || !automatic {
|
|
t.Errorf("expected automatic=true, got %v", evt.Details["automatic"])
|
|
}
|
|
if message, ok := evt.Details["message"].(string); !ok || message != "Freed 10GB" {
|
|
t.Errorf("expected message 'Freed 10GB', got %v", evt.Details["message"])
|
|
}
|
|
}
|
|
}
|
|
if !foundRunbookEvent {
|
|
t.Error("expected to find runbook event in timeline")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordRunbook_EmptyParams(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
// Should not create incident with empty alertID
|
|
store.RecordRunbook("", "runbook-1", "Test", "success", false, "")
|
|
|
|
// Should not create incident with empty runbookID
|
|
store.RecordRunbook("alert-1", "", "Test", "success", false, "")
|
|
}
|
|
|
|
func TestIncidentStore_RecordRunbook_CreatesIncident(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
// RecordRunbook should create incident if none exists
|
|
store.RecordRunbook("new-alert", "runbook-1", "Test Runbook", "completed", false, "")
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier("new-alert")
|
|
if timeline == nil {
|
|
t.Fatal("expected timeline to be created by RecordRunbook")
|
|
}
|
|
if timeline.Status != IncidentStatusOpen {
|
|
t.Errorf("expected status 'open', got %q", timeline.Status)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_ListIncidentsByResource(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 20,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
// Create multiple incidents for the same resource
|
|
for i := 0; i < 5; i++ {
|
|
alert := &alerts.Alert{
|
|
ID: "alert-list-" + string(rune('A'+i)),
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-list-1",
|
|
ResourceName: "vm-list-1",
|
|
StartTime: time.Now().Add(-time.Duration(i) * time.Hour),
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
}
|
|
|
|
// Create incident for different resource
|
|
otherAlert := &alerts.Alert{
|
|
ID: "alert-other",
|
|
Type: "memory",
|
|
Level: alerts.AlertLevelCritical,
|
|
ResourceID: "res-other",
|
|
ResourceName: "vm-other",
|
|
StartTime: time.Now(),
|
|
}
|
|
store.RecordAlertFired(otherAlert)
|
|
|
|
// List all incidents for res-list-1
|
|
incidents := store.ListIncidentsByResource("res-list-1", 0)
|
|
if len(incidents) != 5 {
|
|
t.Errorf("expected 5 incidents for res-list-1, got %d", len(incidents))
|
|
}
|
|
|
|
// List with limit
|
|
incidents = store.ListIncidentsByResource("res-list-1", 3)
|
|
if len(incidents) != 3 {
|
|
t.Errorf("expected 3 incidents with limit, got %d", len(incidents))
|
|
}
|
|
|
|
// List for non-existent resource
|
|
incidents = store.ListIncidentsByResource("res-nonexistent", 0)
|
|
if len(incidents) != 0 {
|
|
t.Errorf("expected 0 incidents for non-existent resource, got %d", len(incidents))
|
|
}
|
|
|
|
// Empty resource ID
|
|
incidents = store.ListIncidentsByResource("", 0)
|
|
if incidents != nil {
|
|
t.Error("expected nil for empty resource ID")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForAlert(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 50,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-format-1",
|
|
Type: "cpu_high",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-format-1",
|
|
ResourceName: "vm-format-1",
|
|
StartTime: time.Now().Add(-10 * time.Minute),
|
|
}
|
|
|
|
store.RecordAlertFired(alert)
|
|
store.RecordAlertAcknowledged(alert, "admin")
|
|
store.RecordAnalysis(alert.ID, "High CPU due to process X", nil)
|
|
|
|
result := store.FormatForAlert(alert.ID, 10)
|
|
|
|
if result == "" {
|
|
t.Fatal("expected non-empty format result")
|
|
}
|
|
|
|
// Check for expected content
|
|
if !strings.Contains(result, "## Incident Memory") {
|
|
t.Error("expected '## Incident Memory' header")
|
|
}
|
|
if !strings.Contains(result, "vm-format-1") {
|
|
t.Error("expected resource name in output")
|
|
}
|
|
if !strings.Contains(result, "cpu_high") {
|
|
t.Error("expected alert type in output")
|
|
}
|
|
if !strings.Contains(result, "Status: open") {
|
|
t.Error("expected status in output")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForAlert_MaxEvents(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
MaxEventsPerIncident: 100,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-max-events",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-1",
|
|
ResourceName: "vm-1",
|
|
}
|
|
|
|
store.RecordAlertFired(alert)
|
|
for i := 0; i < 10; i++ {
|
|
store.RecordAnalysis(alert.ID, "Analysis "+string(rune('A'+i)), nil)
|
|
}
|
|
|
|
// Request only 3 events
|
|
result := store.FormatForAlert(alert.ID, 3)
|
|
if result == "" {
|
|
t.Fatal("expected non-empty result")
|
|
}
|
|
|
|
// Should only have last 3 events in output (count occurrences of timestamps)
|
|
lines := strings.Split(result, "\n")
|
|
eventLines := 0
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "- 20") { // timestamp starts with year
|
|
eventLines++
|
|
}
|
|
}
|
|
if eventLines > 3 {
|
|
t.Errorf("expected max 3 event lines, got %d", eventLines)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForAlert_NoIncident(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
result := store.FormatForAlert("nonexistent-alert", 10)
|
|
if result != "" {
|
|
t.Errorf("expected empty string for non-existent alert, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForResource(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 20,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
// Create multiple incidents for the same resource
|
|
for i := 0; i < 3; i++ {
|
|
alert := &alerts.Alert{
|
|
ID: "alert-res-" + string(rune('A'+i)),
|
|
Type: "disk",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-format-res",
|
|
ResourceName: "storage-format",
|
|
StartTime: time.Now().Add(-time.Duration(i) * time.Hour),
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
if i == 0 {
|
|
store.RecordAlertAcknowledged(alert, "admin")
|
|
}
|
|
}
|
|
|
|
result := store.FormatForResource("res-format-res", 5)
|
|
|
|
if result == "" {
|
|
t.Fatal("expected non-empty format result")
|
|
}
|
|
|
|
if !strings.Contains(result, "## Incident Memory") {
|
|
t.Error("expected '## Incident Memory' header")
|
|
}
|
|
if !strings.Contains(result, "Recent incidents for this resource") {
|
|
t.Error("expected resource incidents header")
|
|
}
|
|
if !strings.Contains(result, "disk") {
|
|
t.Error("expected alert type in output")
|
|
}
|
|
// First incident should show as acknowledged
|
|
if !strings.Contains(result, "acknowledged") {
|
|
t.Error("expected 'acknowledged' status for first incident")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForResource_NoIncidents(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
result := store.FormatForResource("nonexistent-resource", 5)
|
|
if result != "" {
|
|
t.Errorf("expected empty string for resource with no incidents, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForPatrol(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 20,
|
|
MaxAgeDays: 30,
|
|
})
|
|
|
|
// Create incidents for multiple resources
|
|
resources := []string{"vm-1", "vm-2", "storage-1"}
|
|
for i, resName := range resources {
|
|
alert := &alerts.Alert{
|
|
ID: "alert-patrol-" + string(rune('A'+i)),
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-patrol-" + string(rune('1'+i)),
|
|
ResourceName: resName,
|
|
StartTime: time.Now().Add(-time.Duration(i) * time.Hour),
|
|
Message: "High usage detected",
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
store.RecordAnalysis(alert.ID, "Analysis for "+resName, nil)
|
|
}
|
|
|
|
result := store.FormatForPatrol(10)
|
|
|
|
if result == "" {
|
|
t.Fatal("expected non-empty format result")
|
|
}
|
|
|
|
if !strings.Contains(result, "## Incident Memory") {
|
|
t.Error("expected '## Incident Memory' header")
|
|
}
|
|
if !strings.Contains(result, "Recent incidents across infrastructure") {
|
|
t.Error("expected infrastructure-wide header")
|
|
}
|
|
// Should contain resource names
|
|
for _, resName := range resources {
|
|
if !strings.Contains(result, resName) {
|
|
t.Errorf("expected resource name %q in output", resName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForPatrol_WithLimit(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 20,
|
|
})
|
|
|
|
// Create 10 incidents
|
|
for i := 0; i < 10; i++ {
|
|
alert := &alerts.Alert{
|
|
ID: "alert-limit-" + string(rune('A'+i)),
|
|
Type: "memory",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-" + string(rune('A'+i)),
|
|
ResourceName: "vm-" + string(rune('A'+i)),
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
}
|
|
|
|
// Request only 3
|
|
result := store.FormatForPatrol(3)
|
|
|
|
// Count incident lines
|
|
lines := strings.Split(result, "\n")
|
|
incidentLines := 0
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "- 20") {
|
|
incidentLines++
|
|
}
|
|
}
|
|
if incidentLines > 3 {
|
|
t.Errorf("expected max 3 incident lines, got %d", incidentLines)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForPatrol_Empty(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
result := store.FormatForPatrol(10)
|
|
if result != "" {
|
|
t.Errorf("expected empty string for empty store, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_FormatForPatrol_DefaultLimit(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 20,
|
|
})
|
|
|
|
// Create 15 incidents
|
|
for i := 0; i < 15; i++ {
|
|
alert := &alerts.Alert{
|
|
ID: "alert-default-" + string(rune('A'+i)),
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-" + string(rune('A'+i)),
|
|
ResourceName: "vm-" + string(rune('A'+i)),
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
}
|
|
|
|
// Pass 0 limit - should use default of 8
|
|
result := store.FormatForPatrol(0)
|
|
|
|
// Count incident lines - should be at most 8
|
|
lines := strings.Split(result, "\n")
|
|
incidentLines := 0
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "- 20") {
|
|
incidentLines++
|
|
}
|
|
}
|
|
if incidentLines > 8 {
|
|
t.Errorf("expected max 8 incident lines (default), got %d", incidentLines)
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordNote_ByIncidentID(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-note-id",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
ResourceID: "res-note",
|
|
ResourceName: "vm-note",
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
|
|
timeline := store.GetTimelineByAlertIdentifier(alert.ID)
|
|
if timeline == nil {
|
|
t.Fatal("expected timeline")
|
|
}
|
|
|
|
// Add note by incident ID
|
|
ok := store.RecordNote("", timeline.ID, "Test note by incident ID", "operator")
|
|
if !ok {
|
|
t.Fatal("expected note to be saved by incident ID")
|
|
}
|
|
|
|
timeline = store.GetTimelineByAlertIdentifier(alert.ID)
|
|
foundNoteEvent := false
|
|
for _, evt := range timeline.Events {
|
|
if evt.Type == IncidentEventNote {
|
|
foundNoteEvent = true
|
|
if note, ok := evt.Details["note"].(string); !ok || note != "Test note by incident ID" {
|
|
t.Errorf("expected note text, got %v", evt.Details["note"])
|
|
}
|
|
}
|
|
}
|
|
if !foundNoteEvent {
|
|
t.Error("expected to find note event")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordNote_EmptyNote(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
alert := &alerts.Alert{
|
|
ID: "alert-empty-note",
|
|
ResourceID: "res-1",
|
|
}
|
|
store.RecordAlertFired(alert)
|
|
|
|
// Empty note should return false
|
|
ok := store.RecordNote(alert.ID, "", "", "admin")
|
|
if ok {
|
|
t.Error("expected false for empty note")
|
|
}
|
|
|
|
// Whitespace-only note should return false
|
|
ok = store.RecordNote(alert.ID, "", " ", "admin")
|
|
if ok {
|
|
t.Error("expected false for whitespace-only note")
|
|
}
|
|
}
|
|
|
|
func TestIncidentStore_RecordNote_NonexistentIncident(t *testing.T) {
|
|
store := NewIncidentStore(IncidentStoreConfig{
|
|
MaxIncidents: 10,
|
|
})
|
|
|
|
ok := store.RecordNote("nonexistent-alert", "", "Test note", "admin")
|
|
if ok {
|
|
t.Error("expected false for non-existent alert")
|
|
}
|
|
|
|
ok = store.RecordNote("", "nonexistent-incident", "Test note", "admin")
|
|
if ok {
|
|
t.Error("expected false for non-existent incident")
|
|
}
|
|
}
|