Pulse/internal/ai/memory/incidents_test.go
2026-03-20 11:59:40 +00:00

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")
}
}