diff --git a/internal/alerts/history_test.go b/internal/alerts/history_test.go new file mode 100644 index 0000000..a51262d --- /dev/null +++ b/internal/alerts/history_test.go @@ -0,0 +1,492 @@ +package alerts + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// newTestHistoryManager creates a HistoryManager using a temp directory +// that is automatically cleaned up after the test. +func newTestHistoryManager(t *testing.T) *HistoryManager { + t.Helper() + + tempDir := t.TempDir() + + // Create minimal HistoryManager without starting goroutines + hm := &HistoryManager{ + dataDir: tempDir, + historyFile: filepath.Join(tempDir, HistoryFileName), + backupFile: filepath.Join(tempDir, HistoryBackupFileName), + history: make([]HistoryEntry, 0), + saveInterval: 5 * time.Minute, + stopChan: make(chan struct{}), + } + + return hm +} + +func TestGetStats_EmptyHistory(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + stats := hm.GetStats() + + if stats["totalEntries"].(int) != 0 { + t.Errorf("totalEntries = %v, want 0", stats["totalEntries"]) + } + if stats["dataDir"].(string) != hm.dataDir { + t.Errorf("dataDir = %v, want %v", stats["dataDir"], hm.dataDir) + } + if stats["fileSize"].(int64) != 0 { + t.Errorf("fileSize = %v, want 0", stats["fileSize"]) + } +} + +func TestGetStats_WithHistory(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Add some history entries + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: now.Add(-1 * time.Hour)}, + {Alert: Alert{ID: "alert2"}, Timestamp: now.Add(-30 * time.Minute)}, + {Alert: Alert{ID: "alert3"}, Timestamp: now}, + } + + stats := hm.GetStats() + + if stats["totalEntries"].(int) != 3 { + t.Errorf("totalEntries = %v, want 3", stats["totalEntries"]) + } + + oldest := stats["oldestEntry"].(time.Time) + if !oldest.Equal(hm.history[0].Timestamp) { + t.Errorf("oldestEntry = %v, want %v", oldest, hm.history[0].Timestamp) + } + + newest := stats["newestEntry"].(time.Time) + if !newest.Equal(hm.history[2].Timestamp) { + t.Errorf("newestEntry = %v, want %v", newest, hm.history[2].Timestamp) + } +} + +func TestGetFileSize_NonExistent(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + size := hm.getFileSize() + if size != 0 { + t.Errorf("getFileSize for non-existent file = %v, want 0", size) + } +} + +func TestGetFileSize_ExistingFile(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Create a file with known content + content := []byte(`[{"alert":{"id":"test"},"timestamp":"2024-01-01T00:00:00Z"}]`) + if err := os.WriteFile(hm.historyFile, content, 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + size := hm.getFileSize() + if size != int64(len(content)) { + t.Errorf("getFileSize = %v, want %v", size, len(content)) + } +} + +func TestAddAlert(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + alert := Alert{ + ID: "test-alert-1", + Type: "cpu", + Level: AlertLevelWarning, + ResourceName: "pve1", + Message: "CPU high", + } + + hm.AddAlert(alert) + + if len(hm.history) != 1 { + t.Fatalf("history length = %d, want 1", len(hm.history)) + } + + if hm.history[0].Alert.ID != "test-alert-1" { + t.Errorf("alert ID = %s, want test-alert-1", hm.history[0].Alert.ID) + } +} + +func TestGetHistory_WithLimit(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: now.Add(-2 * time.Hour)}, + {Alert: Alert{ID: "alert2"}, Timestamp: now.Add(-1 * time.Hour)}, + {Alert: Alert{ID: "alert3"}, Timestamp: now.Add(-30 * time.Minute)}, + {Alert: Alert{ID: "alert4"}, Timestamp: now.Add(-10 * time.Minute)}, + } + + // Get last 2 entries + results := hm.GetHistory(time.Time{}, 2) + + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + + // Should be newest first + if results[0].ID != "alert4" { + t.Errorf("first result ID = %s, want alert4", results[0].ID) + } + if results[1].ID != "alert3" { + t.Errorf("second result ID = %s, want alert3", results[1].ID) + } +} + +func TestGetHistory_WithSinceFilter(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: now.Add(-2 * time.Hour)}, + {Alert: Alert{ID: "alert2"}, Timestamp: now.Add(-1 * time.Hour)}, + {Alert: Alert{ID: "alert3"}, Timestamp: now.Add(-30 * time.Minute)}, + {Alert: Alert{ID: "alert4"}, Timestamp: now.Add(-10 * time.Minute)}, + } + + // Get entries from last 45 minutes + since := now.Add(-45 * time.Minute) + results := hm.GetHistory(since, 0) + + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2 (alert3 and alert4)", len(results)) + } +} + +func TestGetAllHistory(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: now.Add(-2 * time.Hour)}, + {Alert: Alert{ID: "alert2"}, Timestamp: now.Add(-1 * time.Hour)}, + {Alert: Alert{ID: "alert3"}, Timestamp: now.Add(-30 * time.Minute)}, + } + + results := hm.GetAllHistory(0) + + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } + + // Should be newest first + if results[0].ID != "alert3" { + t.Errorf("first result ID = %s, want alert3", results[0].ID) + } +} + +func TestGetAllHistory_WithLimit(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: now.Add(-2 * time.Hour)}, + {Alert: Alert{ID: "alert2"}, Timestamp: now.Add(-1 * time.Hour)}, + {Alert: Alert{ID: "alert3"}, Timestamp: now.Add(-30 * time.Minute)}, + } + + results := hm.GetAllHistory(2) + + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + + // Should be newest 2, with newest first + if results[0].ID != "alert3" { + t.Errorf("first result ID = %s, want alert3", results[0].ID) + } + if results[1].ID != "alert2" { + t.Errorf("second result ID = %s, want alert2", results[1].ID) + } +} + +func TestRemoveAlert(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: now.Add(-2 * time.Hour)}, + {Alert: Alert{ID: "alert2"}, Timestamp: now.Add(-1 * time.Hour)}, + {Alert: Alert{ID: "alert3"}, Timestamp: now.Add(-30 * time.Minute)}, + } + + hm.RemoveAlert("alert2") + + if len(hm.history) != 2 { + t.Fatalf("history length = %d, want 2", len(hm.history)) + } + + for _, entry := range hm.history { + if entry.Alert.ID == "alert2" { + t.Error("alert2 should have been removed") + } + } +} + +func TestRemoveAlert_NotFound(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: time.Now()}, + } + + // Should not panic or error + hm.RemoveAlert("nonexistent") + + if len(hm.history) != 1 { + t.Errorf("history length = %d, want 1", len(hm.history)) + } +} + +func TestClearAllHistory(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Add some history + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: time.Now()}, + {Alert: Alert{ID: "alert2"}, Timestamp: time.Now()}, + } + + // Create files + _ = os.WriteFile(hm.historyFile, []byte("[]"), 0644) + _ = os.WriteFile(hm.backupFile, []byte("[]"), 0644) + + err := hm.ClearAllHistory() + if err != nil { + t.Fatalf("ClearAllHistory error: %v", err) + } + + if len(hm.history) != 0 { + t.Errorf("history length = %d, want 0", len(hm.history)) + } + + // Files should be removed + if _, err := os.Stat(hm.historyFile); !os.IsNotExist(err) { + t.Error("history file should be removed") + } + if _, err := os.Stat(hm.backupFile); !os.IsNotExist(err) { + t.Error("backup file should be removed") + } +} + +func TestCleanOldEntries(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "old1"}, Timestamp: now.AddDate(0, 0, -40)}, // 40 days old - should be removed + {Alert: Alert{ID: "old2"}, Timestamp: now.AddDate(0, 0, -35)}, // 35 days old - should be removed + {Alert: Alert{ID: "recent1"}, Timestamp: now.AddDate(0, 0, -25)}, // 25 days old - should stay + {Alert: Alert{ID: "recent2"}, Timestamp: now.AddDate(0, 0, -1)}, // 1 day old - should stay + } + + hm.cleanOldEntries() + + if len(hm.history) != 2 { + t.Fatalf("history length = %d, want 2", len(hm.history)) + } + + // Check that only recent entries remain + for _, entry := range hm.history { + if entry.Alert.ID == "old1" || entry.Alert.ID == "old2" { + t.Errorf("old entry %s should have been removed", entry.Alert.ID) + } + } +} + +func TestSaveHistory(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + now := time.Now() + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1", Type: "cpu"}, Timestamp: now}, + {Alert: Alert{ID: "alert2", Type: "memory"}, Timestamp: now}, + } + + err := hm.saveHistory() + if err != nil { + t.Fatalf("saveHistory error: %v", err) + } + + // Verify file was created + if _, err := os.Stat(hm.historyFile); os.IsNotExist(err) { + t.Error("history file should exist after save") + } + + // Read file and verify content + data, err := os.ReadFile(hm.historyFile) + if err != nil { + t.Fatalf("Failed to read history file: %v", err) + } + + if len(data) == 0 { + t.Error("history file should not be empty") + } +} + +func TestLoadHistory_NonExistent(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + err := hm.loadHistory() + if err != nil { + t.Fatalf("loadHistory error: %v", err) + } + + if len(hm.history) != 0 { + t.Errorf("history should be empty for non-existent files, got %d entries", len(hm.history)) + } +} + +func TestLoadHistory_FromMainFile(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Create a history file with a recent timestamp (within MaxHistoryDays) + recentTime := time.Now().Add(-24 * time.Hour).Format(time.RFC3339) + content := `[{"alert":{"id":"test-1","type":"cpu"},"timestamp":"` + recentTime + `"}]` + if err := os.WriteFile(hm.historyFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + err := hm.loadHistory() + if err != nil { + t.Fatalf("loadHistory error: %v", err) + } + + if len(hm.history) != 1 { + t.Fatalf("history length = %d, want 1", len(hm.history)) + } + + if hm.history[0].Alert.ID != "test-1" { + t.Errorf("alert ID = %s, want test-1", hm.history[0].Alert.ID) + } +} + +func TestLoadHistory_FromBackupFile(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Only create backup file with a recent timestamp + recentTime := time.Now().Add(-24 * time.Hour).Format(time.RFC3339) + content := `[{"alert":{"id":"backup-1","type":"memory"},"timestamp":"` + recentTime + `"}]` + if err := os.WriteFile(hm.backupFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create backup file: %v", err) + } + + err := hm.loadHistory() + if err != nil { + t.Fatalf("loadHistory error: %v", err) + } + + if len(hm.history) != 1 { + t.Fatalf("history length = %d, want 1", len(hm.history)) + } + + if hm.history[0].Alert.ID != "backup-1" { + t.Errorf("alert ID = %s, want backup-1", hm.history[0].Alert.ID) + } +} + +func TestLoadHistory_InvalidJSON(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Create an invalid JSON file + if err := os.WriteFile(hm.historyFile, []byte("not valid json{"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + err := hm.loadHistory() + if err == nil { + t.Error("loadHistory should return error for invalid JSON") + } +} + +func TestSaveHistoryWithRetry_Success(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "alert1"}, Timestamp: time.Now()}, + } + + err := hm.saveHistoryWithRetry(3) + if err != nil { + t.Fatalf("saveHistoryWithRetry error: %v", err) + } + + // Verify file was created + if _, err := os.Stat(hm.historyFile); os.IsNotExist(err) { + t.Error("history file should exist after save") + } +} + +func TestSaveHistory_CreatesBackup(t *testing.T) { + t.Parallel() + + hm := newTestHistoryManager(t) + + // Create initial history file + if err := os.WriteFile(hm.historyFile, []byte("[]"), 0644); err != nil { + t.Fatalf("Failed to create initial file: %v", err) + } + + hm.history = []HistoryEntry{ + {Alert: Alert{ID: "new-alert"}, Timestamp: time.Now()}, + } + + err := hm.saveHistory() + if err != nil { + t.Fatalf("saveHistory error: %v", err) + } + + // Backup should exist (created from previous file) + if _, err := os.Stat(hm.backupFile); os.IsNotExist(err) { + t.Error("backup file should exist after save with existing main file") + } +}