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") } } func TestSaveHistoryWithRetry_CreatesBackup(t *testing.T) { t.Parallel() hm := newTestHistoryManager(t) // Create existing history file existingContent := `[{"alert":{"id":"existing-alert"},"timestamp":"2025-01-01T00:00:00Z"}]` if err := os.WriteFile(hm.historyFile, []byte(existingContent), 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.saveHistoryWithRetry(3) if err != nil { t.Fatalf("saveHistoryWithRetry error: %v", err) } // Backup should exist with original content if _, err := os.Stat(hm.backupFile); os.IsNotExist(err) { t.Error("backup file should exist after save") } backupData, err := os.ReadFile(hm.backupFile) if err != nil { t.Fatalf("Failed to read backup file: %v", err) } if string(backupData) != existingContent { t.Errorf("backup content = %s, want %s", backupData, existingContent) } } func TestSaveHistoryWithRetry_EmptyHistory(t *testing.T) { t.Parallel() hm := newTestHistoryManager(t) hm.history = []HistoryEntry{} err := hm.saveHistoryWithRetry(3) if err != nil { t.Fatalf("saveHistoryWithRetry error: %v", err) } data, err := os.ReadFile(hm.historyFile) if err != nil { t.Fatalf("Failed to read history file: %v", err) } if string(data) != "[]" { t.Errorf("empty history should write [], got %s", data) } } func TestSaveHistoryWithRetry_SingleRetry(t *testing.T) { t.Parallel() hm := newTestHistoryManager(t) hm.history = []HistoryEntry{ {Alert: Alert{ID: "test-alert"}, Timestamp: time.Now()}, } // With maxRetries=1, should still succeed on first attempt err := hm.saveHistoryWithRetry(1) if err != nil { t.Fatalf("saveHistoryWithRetry with 1 retry should succeed: %v", err) } if _, err := os.Stat(hm.historyFile); os.IsNotExist(err) { t.Error("history file should exist") } } func TestSaveHistoryWithRetry_ReadOnlyDirectory(t *testing.T) { t.Parallel() tempDir := t.TempDir() // Create subdirectory and make it read-only readOnlyDir := filepath.Join(tempDir, "readonly") if err := os.MkdirAll(readOnlyDir, 0755); err != nil { t.Fatalf("Failed to create readonly dir: %v", err) } hm := &HistoryManager{ dataDir: readOnlyDir, historyFile: filepath.Join(readOnlyDir, HistoryFileName), backupFile: filepath.Join(readOnlyDir, HistoryBackupFileName), history: []HistoryEntry{{Alert: Alert{ID: "test"}, Timestamp: time.Now()}}, stopChan: make(chan struct{}), } // Make directory read-only if err := os.Chmod(readOnlyDir, 0444); err != nil { t.Fatalf("Failed to make dir readonly: %v", err) } t.Cleanup(func() { os.Chmod(readOnlyDir, 0755) // Restore for cleanup }) // Should fail after retries err := hm.saveHistoryWithRetry(2) if err == nil { t.Error("saveHistoryWithRetry should fail on read-only directory") } } func TestSaveHistoryWithRetry_ConcurrentSaves(t *testing.T) { t.Parallel() hm := newTestHistoryManager(t) // Populate some history for i := 0; i < 10; i++ { hm.history = append(hm.history, HistoryEntry{ Alert: Alert{ID: "alert-" + string(rune('0'+i))}, Timestamp: time.Now(), }) } // Run multiple concurrent saves - the saveMu lock should serialize them errChan := make(chan error, 5) for i := 0; i < 5; i++ { go func() { errChan <- hm.saveHistoryWithRetry(3) }() } // Collect errors for i := 0; i < 5; i++ { if err := <-errChan; err != nil { t.Errorf("concurrent save %d failed: %v", i, err) } } // Verify file exists and is valid if _, err := os.Stat(hm.historyFile); os.IsNotExist(err) { t.Error("history file should exist after concurrent saves") } } func TestSaveHistoryWithRetry_SnapshotIsolation(t *testing.T) { t.Parallel() hm := newTestHistoryManager(t) hm.history = []HistoryEntry{ {Alert: Alert{ID: "original-alert"}, Timestamp: time.Now()}, } // Start save in goroutine done := make(chan error) go func() { done <- hm.saveHistoryWithRetry(3) }() // Modify history while save might be in progress // This shouldn't affect the saved content because saveHistoryWithRetry // takes a snapshot under lock time.Sleep(1 * time.Millisecond) hm.mu.Lock() hm.history = append(hm.history, HistoryEntry{ Alert: Alert{ID: "added-during-save"}, Timestamp: time.Now(), }) hm.mu.Unlock() if err := <-done; err != nil { t.Fatalf("saveHistoryWithRetry error: %v", err) } // The file should have been written successfully if _, err := os.Stat(hm.historyFile); os.IsNotExist(err) { t.Error("history file should exist") } }