Pulse/internal/notifications/notifications_test.go
rcourtman b4fb5d2a6a test: Add tests for mergeNVMeTempsIntoDisks, UpdateQueueSnapshot, UpdateDeadLetterCounts, templateFuncMap
- mergeNVMeTempsIntoDisks: 57%→97% (14 tests covering WWN/serial/path matching, NVMe fallback)
- UpdateQueueSnapshot: 57%→100% (5 tests covering nil, gauges, stale key cleanup)
- UpdateDeadLetterCounts: 52%→100% (7 tests covering aggregation, normalization)
- templateFuncMap: 20%→100% (8 tests covering all template helper functions)

Monitoring 44.6%→45.3%, notifications 45.7%→45.9%
2025-12-01 18:14:43 +00:00

1377 lines
36 KiB
Go

package notifications
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
)
func flushPending(n *NotificationManager) {
n.mu.Lock()
if n.queue != nil {
// Tests don't rely on the persistent queue; shutting it down ensures sends happen synchronously.
_ = n.queue.Stop()
n.queue = nil
}
if n.groupTimer != nil {
n.groupTimer.Stop()
n.groupTimer = nil
}
n.mu.Unlock()
n.sendGroupedAlerts()
}
func TestNormalizeAppriseConfig(t *testing.T) {
original := AppriseConfig{
Enabled: true,
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
CLIPath: " ",
TimeoutSeconds: -5,
APIKeyHeader: "",
}
normalized := NormalizeAppriseConfig(original)
if normalized.Mode != AppriseModeCLI {
t.Fatalf("expected default mode cli, got %q", normalized.Mode)
}
if normalized.CLIPath != "apprise" {
t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath)
}
if normalized.TimeoutSeconds != 15 {
t.Fatalf("expected timeout of 15 seconds, got %d", normalized.TimeoutSeconds)
}
if !normalized.Enabled {
t.Fatalf("expected config to remain enabled when targets exist")
}
if len(normalized.Targets) != 1 || normalized.Targets[0] != "discord://token" {
t.Fatalf("unexpected targets normalization result: %#v", normalized.Targets)
}
if normalized.APIKeyHeader != "X-API-KEY" {
t.Fatalf("expected default API key header, got %q", normalized.APIKeyHeader)
}
// When all targets removed, enabled should reset to false
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
if empty.Enabled {
t.Fatalf("expected enabled to be false when no targets configured")
}
httpConfig := NormalizeAppriseConfig(AppriseConfig{
Enabled: true,
Mode: AppriseModeHTTP,
ServerURL: "https://apprise.example.com/api/",
APIKey: " secret ",
APIKeyHeader: " X-Token ",
TimeoutSeconds: 200,
})
if httpConfig.Mode != AppriseModeHTTP {
t.Fatalf("expected HTTP mode, got %q", httpConfig.Mode)
}
if httpConfig.ServerURL != "https://apprise.example.com/api" {
t.Fatalf("expected server URL to be trimmed, got %q", httpConfig.ServerURL)
}
if httpConfig.APIKey != "secret" {
t.Fatalf("expected API key to be trimmed, got %q", httpConfig.APIKey)
}
if httpConfig.APIKeyHeader != "X-Token" {
t.Fatalf("expected API key header to be trimmed, got %q", httpConfig.APIKeyHeader)
}
if httpConfig.TimeoutSeconds != 120 {
t.Fatalf("expected timeout to clamp to 120, got %d", httpConfig.TimeoutSeconds)
}
if !httpConfig.Enabled {
t.Fatalf("expected HTTP config with server URL to remain enabled")
}
disabledHTTP := NormalizeAppriseConfig(AppriseConfig{
Enabled: true,
Mode: AppriseModeHTTP,
})
if disabledHTTP.Enabled {
t.Fatalf("expected HTTP config without server URL to disable notifications")
}
}
func TestSetCooldownClampsNegativeValues(t *testing.T) {
nm := NewNotificationManager("")
nm.SetCooldown(-10)
nm.mu.RLock()
if nm.cooldown != 0 {
nm.mu.RUnlock()
t.Fatalf("expected cooldown to clamp to zero, got %s", nm.cooldown)
}
nm.mu.RUnlock()
nm.SetCooldown(5)
nm.mu.RLock()
if nm.cooldown != 5*time.Minute {
nm.mu.RUnlock()
t.Fatalf("expected cooldown of five minutes, got %s", nm.cooldown)
}
nm.mu.RUnlock()
}
func TestSetGroupingWindowClampsNegativeValues(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingWindow(-60)
nm.mu.RLock()
if nm.groupWindow != 0 {
nm.mu.RUnlock()
t.Fatalf("expected grouping window to clamp to zero, got %s", nm.groupWindow)
}
nm.mu.RUnlock()
nm.SetGroupingWindow(120)
nm.mu.RLock()
if nm.groupWindow != 120*time.Second {
nm.mu.RUnlock()
t.Fatalf("expected grouping window of 120 seconds, got %s", nm.groupWindow)
}
nm.mu.RUnlock()
}
func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingWindow(0)
nm.SetEmailConfig(EmailConfig{Enabled: false})
done := make(chan struct{})
var capturedArgs []string
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
if path != "apprise" {
t.Fatalf("expected CLI path 'apprise', got %q", path)
}
capturedArgs = append([]string(nil), args...)
close(done)
return []byte("success"), nil
}
nm.SetAppriseConfig(AppriseConfig{
Enabled: true,
Targets: []string{"discord://token"},
TimeoutSeconds: 10,
})
alert := &alerts.Alert{
ID: "test",
Type: "cpu",
Level: alerts.AlertLevelCritical,
ResourceID: "vm-100",
ResourceName: "vm-100",
Message: "CPU usage high",
Value: 95,
Threshold: 90,
StartTime: time.Now().Add(-time.Minute),
LastSeen: time.Now(),
}
nm.mu.Lock()
nm.pendingAlerts = append(nm.pendingAlerts, alert)
nm.mu.Unlock()
nm.sendGroupedAlerts()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for Apprise executor to run")
}
if len(capturedArgs) == 0 {
t.Fatalf("expected Apprise executor to receive arguments")
}
if capturedArgs[len(capturedArgs)-1] != "discord://token" {
t.Fatalf("expected target URL as last argument, got %v", capturedArgs)
}
}
func TestSendGroupedAppriseHTTP(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
nm := NewNotificationManager("https://pulse.local")
defer nm.Stop()
nm.SetGroupingWindow(0)
nm.SetEmailConfig(EmailConfig{Enabled: false})
type apprisePayload struct {
Body string `json:"body"`
Title string `json:"title"`
Type string `json:"type"`
URLs []string `json:"urls"`
}
type capturedRequest struct {
Method string
Path string
ContentType string
APIKey string
Payload apprisePayload
}
requests := make(chan capturedRequest, 1)
errs := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
errs <- err
w.WriteHeader(http.StatusInternalServerError)
return
}
var payload apprisePayload
if err := json.Unmarshal(body, &payload); err != nil {
errs <- err
w.WriteHeader(http.StatusBadRequest)
return
}
requests <- capturedRequest{
Method: r.Method,
Path: r.URL.Path,
ContentType: r.Header.Get("Content-Type"),
APIKey: r.Header.Get("X-Test-Key"),
Payload: payload,
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
// Allow localhost for test server (SSRF protection normally blocks this)
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1"); err != nil {
t.Fatalf("failed to configure allowlist: %v", err)
}
nm.SetAppriseConfig(AppriseConfig{
Enabled: true,
Mode: AppriseModeHTTP,
ServerURL: server.URL,
ConfigKey: "primary",
APIKey: "secret",
APIKeyHeader: "X-Test-Key",
Targets: []string{"discord://token"},
TimeoutSeconds: 10,
})
alert := &alerts.Alert{
ID: "test",
Type: "cpu",
Level: alerts.AlertLevelCritical,
ResourceID: "vm-100",
ResourceName: "vm-100",
Message: "CPU usage high",
Value: 95,
Threshold: 90,
StartTime: time.Now().Add(-time.Minute),
LastSeen: time.Now(),
}
nm.mu.Lock()
nm.pendingAlerts = append(nm.pendingAlerts, alert)
nm.mu.Unlock()
nm.sendGroupedAlerts()
var req capturedRequest
select {
case req = <-requests:
case err := <-errs:
t.Fatalf("server error: %v", err)
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for Apprise API request")
}
if req.Method != http.MethodPost {
t.Fatalf("expected POST request, got %s", req.Method)
}
if req.Path != "/notify/primary" {
t.Fatalf("expected notify path with config key, got %s", req.Path)
}
if req.ContentType != "application/json" {
t.Fatalf("expected JSON content type, got %s", req.ContentType)
}
if req.APIKey != "secret" {
t.Fatalf("expected API key header to be set, got %q", req.APIKey)
}
if req.Payload.Title != "Pulse alert: vm-100" {
t.Fatalf("unexpected title: %s", req.Payload.Title)
}
if req.Payload.Type != "failure" {
t.Fatalf("expected failure notification type, got %s", req.Payload.Type)
}
if len(req.Payload.URLs) != 1 || req.Payload.URLs[0] != "discord://token" {
t.Fatalf("unexpected URLs in payload: %#v", req.Payload.URLs)
}
if !strings.Contains(req.Payload.Body, "CPU usage high") {
t.Fatalf("expected alert message in payload body, got %s", req.Payload.Body)
}
if !strings.Contains(req.Payload.Body, "Dashboard: https://pulse.local") {
t.Fatalf("expected dashboard link in payload body, got %s", req.Payload.Body)
}
}
func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) {
nm := NewNotificationManager("")
nm.SetCooldown(1) // 1 minute cooldown
nm.SetGroupingWindow(3600) // keep timer from firing immediately
alertStart := time.Now().Add(-time.Minute)
alertA := &alerts.Alert{
ID: "vm-100-memory",
Type: "memory",
Level: alerts.AlertLevelWarning,
StartTime: alertStart,
}
nm.SendAlert(alertA)
flushPending(nm)
nm.mu.RLock()
firstRecord, ok := nm.lastNotified[alertA.ID]
nm.mu.RUnlock()
if !ok {
t.Fatalf("first notification not recorded")
}
nm.SendAlert(alertA)
nm.mu.RLock()
pendingAfter := len(nm.pendingAlerts)
nm.mu.RUnlock()
if pendingAfter != 0 {
t.Fatalf("cooldown alert should not be queued, found %d pending", pendingAfter)
}
alertRestart := &alerts.Alert{
ID: "vm-100-memory",
Type: "memory",
Level: alerts.AlertLevelWarning,
StartTime: alertStart.Add(time.Minute),
}
nm.SendAlert(alertRestart)
flushPending(nm)
nm.mu.RLock()
recordAfter := nm.lastNotified[alertRestart.ID]
nm.mu.RUnlock()
if !recordAfter.alertStart.Equal(alertRestart.StartTime) {
t.Fatalf("expected alertStart %v, got %v", alertRestart.StartTime, recordAfter.alertStart)
}
if !recordAfter.lastSent.After(firstRecord.lastSent) {
t.Fatalf("lastSent was not updated for new alert instance")
}
}
func TestCancelAlertRemovesPending(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingWindow(120)
alertA := &alerts.Alert{
ID: "vm-100-disk",
Type: "disk",
Level: alerts.AlertLevelWarning,
StartTime: time.Now(),
}
alertB := &alerts.Alert{
ID: "vm-101-disk",
Type: "disk",
Level: alerts.AlertLevelWarning,
StartTime: time.Now(),
}
nm.SendAlert(alertA)
nm.SendAlert(alertB)
nm.CancelAlert(alertA.ID)
nm.mu.RLock()
remaining := make([]string, 0, len(nm.pendingAlerts))
for _, pending := range nm.pendingAlerts {
if pending != nil {
remaining = append(remaining, pending.ID)
}
}
groupTimerActive := nm.groupTimer != nil
nm.mu.RUnlock()
if len(remaining) != 1 || remaining[0] != alertB.ID {
t.Fatalf("expected only %s to remain pending, got %v", alertB.ID, remaining)
}
if !groupTimerActive {
t.Fatalf("expected grouping timer to remain active while other alerts pending")
}
nm.CancelAlert(alertB.ID)
nm.mu.RLock()
if len(nm.pendingAlerts) != 0 {
nm.mu.RUnlock()
t.Fatalf("expected no pending alerts after cancelling all, found %d", len(nm.pendingAlerts))
}
timerStopped := nm.groupTimer == nil
nm.mu.RUnlock()
if !timerStopped {
t.Fatalf("expected grouping timer to be cleared when no alerts remain")
}
}
func TestConvertWebhookCustomFields(t *testing.T) {
if result := convertWebhookCustomFields(nil); result != nil {
t.Fatalf("expected nil for empty input, got %#v", result)
}
original := map[string]string{
"app_token": "abc123",
"user_token": "user456",
}
converted := convertWebhookCustomFields(original)
if len(converted) != len(original) {
t.Fatalf("expected %d keys, got %d", len(original), len(converted))
}
for key, value := range original {
if got, ok := converted[key]; !ok || got != value {
t.Fatalf("expected %s=%s, got %v (present=%v)", key, value, got, ok)
}
}
// Mutate original map and ensure converted copy remains unchanged
original["extra"] = "new-value"
if _, ok := converted["extra"]; ok {
t.Fatalf("expected converted map to be independent of original mutations")
}
}
func TestRenderWebhookURL_PathEncoding(t *testing.T) {
data := WebhookPayloadData{
Message: "CPU spike detected",
}
result, err := renderWebhookURL("https://example.com/alerts/{{.Message}}", data)
if err != nil {
t.Fatalf("expected no error rendering URL template, got %v", err)
}
expected := "https://example.com/alerts/CPU%20spike%20detected"
if result != expected {
t.Fatalf("expected %s, got %s", expected, result)
}
}
func TestRenderWebhookURL_QueryEncoding(t *testing.T) {
data := WebhookPayloadData{
Message: "CPU & Memory > 90%",
}
result, err := renderWebhookURL("https://hooks.example.com?msg={{urlquery .Message}}", data)
if err != nil {
t.Fatalf("expected no error rendering URL template, got %v", err)
}
expected := "https://hooks.example.com?msg=CPU+%26+Memory+%3E+90%25"
if result != expected {
t.Fatalf("expected %s, got %s", expected, result)
}
}
func TestRenderWebhookURL_InvalidTemplate(t *testing.T) {
_, err := renderWebhookURL("https://example.com/{{.Missing", WebhookPayloadData{})
if err == nil {
t.Fatalf("expected error for invalid URL template, got nil")
}
}
func TestRenderWebhookURL_ErrorPaths(t *testing.T) {
tests := []struct {
name string
urlTemplate string
data WebhookPayloadData
wantErr string
}{
{
name: "empty URL template",
urlTemplate: "",
data: WebhookPayloadData{},
wantErr: "webhook URL cannot be empty",
},
{
name: "whitespace-only URL template",
urlTemplate: " \t\n ",
data: WebhookPayloadData{},
wantErr: "webhook URL cannot be empty",
},
{
name: "invalid template syntax",
urlTemplate: "https://example.com/{{.Unclosed",
data: WebhookPayloadData{},
wantErr: "invalid webhook URL template",
},
{
name: "template execution error - undefined function",
urlTemplate: "https://example.com/{{undefined_func .Message}}",
data: WebhookPayloadData{Message: "test"},
wantErr: "invalid webhook URL template",
},
{
name: "template produces empty URL",
urlTemplate: "{{if false}}https://example.com{{end}}",
data: WebhookPayloadData{},
wantErr: "webhook URL template produced empty URL",
},
{
name: "template renders to missing scheme",
urlTemplate: "{{.Message}}/path",
data: WebhookPayloadData{Message: "example.com"},
wantErr: "missing scheme or host",
},
{
name: "template renders to missing host",
urlTemplate: "{{.Message}}://",
data: WebhookPayloadData{Message: "https"},
wantErr: "missing scheme or host",
},
{
name: "template renders to relative path",
urlTemplate: "/{{.Message}}/webhook",
data: WebhookPayloadData{Message: "api"},
wantErr: "missing scheme or host",
},
{
name: "template renders to unparseable URL - malformed IPv6",
urlTemplate: "http://[{{.Message}}",
data: WebhookPayloadData{Message: "::1"},
wantErr: "invalid URL",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := renderWebhookURL(tt.urlTemplate, tt.data)
if err == nil {
t.Fatalf("expected error containing %q, got nil (result: %q)", tt.wantErr, result)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
func TestRenderWebhookURL_SuccessCases(t *testing.T) {
tests := []struct {
name string
urlTemplate string
data WebhookPayloadData
want string
}{
{
name: "static URL - no template",
urlTemplate: "https://example.com/webhook",
data: WebhookPayloadData{},
want: "https://example.com/webhook",
},
{
name: "URL with whitespace trimmed",
urlTemplate: " https://example.com/webhook ",
data: WebhookPayloadData{},
want: "https://example.com/webhook",
},
{
name: "URL with template variable in path",
urlTemplate: "https://example.com/{{.ResourceType}}/alert",
data: WebhookPayloadData{ResourceType: "vm"},
want: "https://example.com/vm/alert",
},
{
name: "URL with urlquery encoding",
urlTemplate: "https://example.com?msg={{urlquery .Message}}",
data: WebhookPayloadData{Message: "hello world"},
want: "https://example.com?msg=hello+world",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := renderWebhookURL(tt.urlTemplate, tt.data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != tt.want {
t.Fatalf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestSendTestNotificationApprise(t *testing.T) {
nm := NewNotificationManager("")
nm.SetEmailConfig(EmailConfig{Enabled: false})
// Test 1: Apprise not enabled should return error
nm.SetAppriseConfig(AppriseConfig{
Enabled: false,
Targets: []string{"discord://token"},
})
err := nm.SendTestNotification("apprise")
if err == nil {
t.Fatalf("expected error when Apprise is disabled, got nil")
}
if !strings.Contains(err.Error(), "not enabled") {
t.Fatalf("expected 'not enabled' error, got: %v", err)
}
// Test 2: Apprise enabled with CLI mode should invoke executor
done := make(chan struct{})
var capturedArgs []string
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
if path != "apprise" {
t.Fatalf("expected CLI path 'apprise', got %q", path)
}
capturedArgs = append([]string(nil), args...)
close(done)
return []byte("success"), nil
}
nm.SetAppriseConfig(AppriseConfig{
Enabled: true,
Targets: []string{"discord://token"},
TimeoutSeconds: 10,
})
err = nm.SendTestNotification("apprise")
if err != nil {
t.Fatalf("expected no error when testing Apprise, got: %v", err)
}
// Wait for the executor to be called
select {
case <-done:
// Success - executor was called
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for Apprise executor to be called")
}
// Verify the arguments contain the target
foundTarget := false
for _, arg := range capturedArgs {
if arg == "discord://token" {
foundTarget = true
break
}
}
if !foundTarget {
t.Fatalf("expected target 'discord://token' in args, got: %v", capturedArgs)
}
}
func TestSendTestAppriseWithConfig(t *testing.T) {
nm := NewNotificationManager("")
defer nm.Stop()
// Disabled config should fail
err := nm.SendTestAppriseWithConfig(AppriseConfig{
Enabled: false,
Targets: []string{"discord://token"},
})
if err == nil || !strings.Contains(err.Error(), "not enabled") {
t.Fatalf("expected not enabled error, got %v", err)
}
done := make(chan struct{})
var cliPath string
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
cliPath = path
close(done)
return []byte("ok"), nil
}
err = nm.SendTestAppriseWithConfig(AppriseConfig{
Enabled: true,
Mode: AppriseModeCLI,
Targets: []string{"discord://token"},
})
if err != nil {
t.Fatalf("expected no error for valid Apprise config, got %v", err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for Apprise test execution")
}
if cliPath != "apprise" {
t.Fatalf("expected default CLI path 'apprise', got %q", cliPath)
}
}
func TestSendTestNotificationAppriseHTTP(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
nm := NewNotificationManager("")
defer nm.Stop()
nm.SetEmailConfig(EmailConfig{Enabled: false})
type apprisePayload struct {
Body string `json:"body"`
Title string `json:"title"`
Type string `json:"type"`
URLs []string `json:"urls"`
}
requests := make(chan apprisePayload, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
var payload apprisePayload
if err := json.Unmarshal(body, &payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
requests <- payload
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer server.Close()
// Allow localhost for test server (SSRF protection normally blocks this)
if err := nm.UpdateAllowedPrivateCIDRs("127.0.0.1"); err != nil {
t.Fatalf("failed to configure allowlist: %v", err)
}
nm.SetAppriseConfig(AppriseConfig{
Enabled: true,
Mode: AppriseModeHTTP,
ServerURL: server.URL,
ConfigKey: "test-key",
TimeoutSeconds: 10,
})
err := nm.SendTestNotification("apprise")
if err != nil {
t.Fatalf("expected no error when testing Apprise HTTP, got: %v", err)
}
// Wait for the HTTP request
select {
case payload := <-requests:
// Verify the payload contains test alert information
if payload.Title == "" {
t.Fatalf("expected non-empty title in Apprise payload")
}
if payload.Body == "" {
t.Fatalf("expected non-empty body in Apprise payload")
}
if !strings.Contains(payload.Body, "test alert") && !strings.Contains(payload.Body, "Test Resource") {
t.Fatalf("expected test alert content in body, got: %s", payload.Body)
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for Apprise HTTP request")
}
}
func TestPublicURL(t *testing.T) {
t.Run("set and get URL", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetPublicURL("https://pulse.example.com")
got := nm.GetPublicURL()
if got != "https://pulse.example.com" {
t.Fatalf("expected https://pulse.example.com, got %q", got)
}
})
t.Run("empty string is no-op", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetPublicURL("https://pulse.example.com")
nm.SetPublicURL("")
got := nm.GetPublicURL()
if got != "https://pulse.example.com" {
t.Fatalf("expected URL to remain unchanged, got %q", got)
}
})
t.Run("trailing slash is trimmed", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetPublicURL("https://pulse.example.com/")
got := nm.GetPublicURL()
if got != "https://pulse.example.com" {
t.Fatalf("expected trailing slash to be trimmed, got %q", got)
}
})
t.Run("whitespace is trimmed", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetPublicURL(" https://pulse.example.com ")
got := nm.GetPublicURL()
if got != "https://pulse.example.com" {
t.Fatalf("expected whitespace to be trimmed, got %q", got)
}
})
t.Run("same URL twice is no-op", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetPublicURL("https://pulse.example.com")
nm.mu.RLock()
urlBefore := nm.publicURL
nm.mu.RUnlock()
nm.SetPublicURL("https://pulse.example.com")
nm.mu.RLock()
urlAfter := nm.publicURL
nm.mu.RUnlock()
if urlBefore != urlAfter {
t.Fatalf("expected URL to remain unchanged")
}
})
t.Run("whitespace-only is no-op", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetPublicURL("https://pulse.example.com")
nm.SetPublicURL(" ")
got := nm.GetPublicURL()
if got != "https://pulse.example.com" {
t.Fatalf("expected URL to remain unchanged after whitespace-only set, got %q", got)
}
})
}
func TestGetAppriseConfigReturnsCopy(t *testing.T) {
nm := NewNotificationManager("")
nm.SetAppriseConfig(AppriseConfig{
Enabled: true,
Targets: []string{"discord://token1", "slack://token2"},
TimeoutSeconds: 30,
})
// Get a copy of the config
configCopy := nm.GetAppriseConfig()
// Modify the returned copy
configCopy.Targets = append(configCopy.Targets, "telegram://token3")
configCopy.Enabled = false
configCopy.TimeoutSeconds = 60
// Get another copy and verify the internal state wasn't affected
configAfter := nm.GetAppriseConfig()
if !configAfter.Enabled {
t.Fatalf("modifying returned copy should not affect internal enabled state")
}
if configAfter.TimeoutSeconds != 30 {
t.Fatalf("expected timeout 30, got %d", configAfter.TimeoutSeconds)
}
if len(configAfter.Targets) != 2 {
t.Fatalf("expected 2 targets, got %d", len(configAfter.Targets))
}
if configAfter.Targets[0] != "discord://token1" || configAfter.Targets[1] != "slack://token2" {
t.Fatalf("internal targets were modified: %v", configAfter.Targets)
}
}
func TestNotifyOnResolve(t *testing.T) {
t.Run("default value is true", func(t *testing.T) {
nm := NewNotificationManager("")
if !nm.GetNotifyOnResolve() {
t.Fatalf("expected default notifyOnResolve to be true")
}
})
t.Run("set true and get", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetNotifyOnResolve(true)
if !nm.GetNotifyOnResolve() {
t.Fatalf("expected notifyOnResolve to be true after setting")
}
})
t.Run("set false and get", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetNotifyOnResolve(false)
if nm.GetNotifyOnResolve() {
t.Fatalf("expected notifyOnResolve to be false after setting")
}
})
}
func TestGroupingOptions(t *testing.T) {
t.Run("byNode=true, byGuest=false", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingOptions(true, false)
nm.mu.RLock()
byNode := nm.groupByNode
byGuest := nm.groupByGuest
nm.mu.RUnlock()
if !byNode {
t.Fatalf("expected groupByNode to be true")
}
if byGuest {
t.Fatalf("expected groupByGuest to be false")
}
})
t.Run("byNode=false, byGuest=true", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingOptions(false, true)
nm.mu.RLock()
byNode := nm.groupByNode
byGuest := nm.groupByGuest
nm.mu.RUnlock()
if byNode {
t.Fatalf("expected groupByNode to be false")
}
if !byGuest {
t.Fatalf("expected groupByGuest to be true")
}
})
t.Run("both true", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingOptions(true, true)
nm.mu.RLock()
byNode := nm.groupByNode
byGuest := nm.groupByGuest
nm.mu.RUnlock()
if !byNode {
t.Fatalf("expected groupByNode to be true")
}
if !byGuest {
t.Fatalf("expected groupByGuest to be true")
}
})
t.Run("both false", func(t *testing.T) {
nm := NewNotificationManager("")
nm.SetGroupingOptions(false, false)
nm.mu.RLock()
byNode := nm.groupByNode
byGuest := nm.groupByGuest
nm.mu.RUnlock()
if byNode {
t.Fatalf("expected groupByNode to be false")
}
if byGuest {
t.Fatalf("expected groupByGuest to be false")
}
})
}
func TestWebhookAddAndGet(t *testing.T) {
t.Run("add webhook and retrieve", func(t *testing.T) {
nm := NewNotificationManager("")
webhook := WebhookConfig{
ID: "webhook-1",
Name: "Test Webhook",
URL: "https://example.com/hook",
Method: "POST",
Enabled: true,
Service: "generic",
}
nm.AddWebhook(webhook)
webhooks := nm.GetWebhooks()
if len(webhooks) != 1 {
t.Fatalf("expected 1 webhook, got %d", len(webhooks))
}
if webhooks[0].ID != "webhook-1" {
t.Fatalf("expected webhook ID 'webhook-1', got %q", webhooks[0].ID)
}
if webhooks[0].Name != "Test Webhook" {
t.Fatalf("expected webhook name 'Test Webhook', got %q", webhooks[0].Name)
}
})
t.Run("add multiple webhooks", func(t *testing.T) {
nm := NewNotificationManager("")
nm.AddWebhook(WebhookConfig{ID: "webhook-1", Name: "First", URL: "https://example.com/1"})
nm.AddWebhook(WebhookConfig{ID: "webhook-2", Name: "Second", URL: "https://example.com/2"})
nm.AddWebhook(WebhookConfig{ID: "webhook-3", Name: "Third", URL: "https://example.com/3"})
webhooks := nm.GetWebhooks()
if len(webhooks) != 3 {
t.Fatalf("expected 3 webhooks, got %d", len(webhooks))
}
ids := make(map[string]bool)
for _, wh := range webhooks {
ids[wh.ID] = true
}
if !ids["webhook-1"] || !ids["webhook-2"] || !ids["webhook-3"] {
t.Fatalf("missing expected webhook IDs: %v", ids)
}
})
t.Run("get webhooks returns empty slice when none", func(t *testing.T) {
nm := NewNotificationManager("")
webhooks := nm.GetWebhooks()
if webhooks == nil {
t.Fatalf("expected empty slice, got nil")
}
if len(webhooks) != 0 {
t.Fatalf("expected 0 webhooks, got %d", len(webhooks))
}
})
}
func TestWebhookUpdate(t *testing.T) {
t.Run("update existing webhook", func(t *testing.T) {
nm := NewNotificationManager("")
nm.AddWebhook(WebhookConfig{
ID: "webhook-1",
Name: "Original Name",
URL: "https://example.com/original",
Enabled: true,
})
err := nm.UpdateWebhook("webhook-1", WebhookConfig{
ID: "webhook-1",
Name: "Updated Name",
URL: "https://example.com/updated",
Enabled: false,
})
if err != nil {
t.Fatalf("expected no error updating webhook, got %v", err)
}
webhooks := nm.GetWebhooks()
if len(webhooks) != 1 {
t.Fatalf("expected 1 webhook, got %d", len(webhooks))
}
if webhooks[0].Name != "Updated Name" {
t.Fatalf("expected name 'Updated Name', got %q", webhooks[0].Name)
}
if webhooks[0].URL != "https://example.com/updated" {
t.Fatalf("expected URL 'https://example.com/updated', got %q", webhooks[0].URL)
}
if webhooks[0].Enabled {
t.Fatalf("expected enabled to be false")
}
})
t.Run("update non-existent webhook returns error", func(t *testing.T) {
nm := NewNotificationManager("")
err := nm.UpdateWebhook("non-existent", WebhookConfig{
ID: "non-existent",
Name: "Test",
})
if err == nil {
t.Fatalf("expected error updating non-existent webhook, got nil")
}
if !strings.Contains(err.Error(), "webhook not found") {
t.Fatalf("expected 'webhook not found' error, got: %v", err)
}
})
}
func TestWebhookDelete(t *testing.T) {
t.Run("delete existing webhook", func(t *testing.T) {
nm := NewNotificationManager("")
nm.AddWebhook(WebhookConfig{ID: "webhook-1", Name: "First"})
nm.AddWebhook(WebhookConfig{ID: "webhook-2", Name: "Second"})
err := nm.DeleteWebhook("webhook-1")
if err != nil {
t.Fatalf("expected no error deleting webhook, got %v", err)
}
webhooks := nm.GetWebhooks()
if len(webhooks) != 1 {
t.Fatalf("expected 1 webhook after delete, got %d", len(webhooks))
}
if webhooks[0].ID != "webhook-2" {
t.Fatalf("expected remaining webhook ID 'webhook-2', got %q", webhooks[0].ID)
}
})
t.Run("delete non-existent webhook returns error", func(t *testing.T) {
nm := NewNotificationManager("")
err := nm.DeleteWebhook("non-existent")
if err == nil {
t.Fatalf("expected error deleting non-existent webhook, got nil")
}
if !strings.Contains(err.Error(), "webhook not found") {
t.Fatalf("expected 'webhook not found' error, got: %v", err)
}
})
t.Run("delete from middle of list", func(t *testing.T) {
nm := NewNotificationManager("")
nm.AddWebhook(WebhookConfig{ID: "webhook-1", Name: "First"})
nm.AddWebhook(WebhookConfig{ID: "webhook-2", Name: "Second"})
nm.AddWebhook(WebhookConfig{ID: "webhook-3", Name: "Third"})
err := nm.DeleteWebhook("webhook-2")
if err != nil {
t.Fatalf("expected no error deleting middle webhook, got %v", err)
}
webhooks := nm.GetWebhooks()
if len(webhooks) != 2 {
t.Fatalf("expected 2 webhooks after delete, got %d", len(webhooks))
}
ids := make(map[string]bool)
for _, wh := range webhooks {
ids[wh.ID] = true
}
if !ids["webhook-1"] || !ids["webhook-3"] {
t.Fatalf("expected webhook-1 and webhook-3 to remain, got: %v", ids)
}
if ids["webhook-2"] {
t.Fatalf("webhook-2 should have been deleted")
}
})
}
func TestTemplateFuncMap(t *testing.T) {
funcs := templateFuncMap()
t.Run("title function", func(t *testing.T) {
titleFn := funcs["title"].(func(string) string)
// Empty string returns empty
if got := titleFn(""); got != "" {
t.Fatalf("expected empty string, got %q", got)
}
// Single character uppercased
if got := titleFn("a"); got != "A" {
t.Fatalf("expected 'A', got %q", got)
}
// Already uppercase single character
if got := titleFn("Z"); got != "Z" {
t.Fatalf("expected 'Z', got %q", got)
}
// Multi-character: first upper, rest lower
if got := titleFn("HELLO"); got != "Hello" {
t.Fatalf("expected 'Hello', got %q", got)
}
if got := titleFn("hello"); got != "Hello" {
t.Fatalf("expected 'Hello', got %q", got)
}
if got := titleFn("hElLo"); got != "Hello" {
t.Fatalf("expected 'Hello', got %q", got)
}
})
t.Run("upper function", func(t *testing.T) {
upperFn := funcs["upper"].(func(string) string)
if got := upperFn("hello"); got != "HELLO" {
t.Fatalf("expected 'HELLO', got %q", got)
}
if got := upperFn(""); got != "" {
t.Fatalf("expected empty string, got %q", got)
}
if got := upperFn("Hello World"); got != "HELLO WORLD" {
t.Fatalf("expected 'HELLO WORLD', got %q", got)
}
})
t.Run("lower function", func(t *testing.T) {
lowerFn := funcs["lower"].(func(string) string)
if got := lowerFn("HELLO"); got != "hello" {
t.Fatalf("expected 'hello', got %q", got)
}
if got := lowerFn(""); got != "" {
t.Fatalf("expected empty string, got %q", got)
}
if got := lowerFn("Hello World"); got != "hello world" {
t.Fatalf("expected 'hello world', got %q", got)
}
})
t.Run("printf function", func(t *testing.T) {
printfFn := funcs["printf"].(func(string, ...any) string)
if got := printfFn("hello %s", "world"); got != "hello world" {
t.Fatalf("expected 'hello world', got %q", got)
}
if got := printfFn("value: %d", 42); got != "value: 42" {
t.Fatalf("expected 'value: 42', got %q", got)
}
if got := printfFn("%.2f%%", 95.5); got != "95.50%" {
t.Fatalf("expected '95.50%%', got %q", got)
}
})
t.Run("urlquery function", func(t *testing.T) {
urlqueryFn := funcs["urlquery"].(func(...any) string)
if got := urlqueryFn("hello world"); got != "hello+world" {
t.Fatalf("expected 'hello+world', got %q", got)
}
if got := urlqueryFn("a=b&c=d"); got != "a%3Db%26c%3Dd" {
t.Fatalf("expected 'a%%3Db%%26c%%3Dd', got %q", got)
}
if got := urlqueryFn("special: +/?#"); got != "special%3A+%2B%2F%3F%23" {
t.Fatalf("expected 'special%%3A+%%2B%%2F%%3F%%23', got %q", got)
}
})
t.Run("urlencode function (alias)", func(t *testing.T) {
urlencodeFn := funcs["urlencode"].(func(...any) string)
// Should behave identically to urlquery
if got := urlencodeFn("hello world"); got != "hello+world" {
t.Fatalf("expected 'hello+world', got %q", got)
}
if got := urlencodeFn("test@example.com"); got != "test%40example.com" {
t.Fatalf("expected 'test%%40example.com', got %q", got)
}
})
t.Run("urlpath function", func(t *testing.T) {
urlpathFn := funcs["urlpath"].(func(string) string)
// Spaces encoded as %20, not +
if got := urlpathFn("hello world"); got != "hello%20world" {
t.Fatalf("expected 'hello%%20world', got %q", got)
}
// Slashes encoded
if got := urlpathFn("path/to/file"); got != "path%2Fto%2Ffile" {
t.Fatalf("expected 'path%%2Fto%%2Ffile', got %q", got)
}
if got := urlpathFn(""); got != "" {
t.Fatalf("expected empty string, got %q", got)
}
})
t.Run("pathescape function", func(t *testing.T) {
pathescapeFn := funcs["pathescape"].(func(string) string)
// Should behave identically to urlpath
if got := pathescapeFn("hello world"); got != "hello%20world" {
t.Fatalf("expected 'hello%%20world', got %q", got)
}
if got := pathescapeFn("segment/with/slashes"); got != "segment%2Fwith%2Fslashes" {
t.Fatalf("expected 'segment%%2Fwith%%2Fslashes', got %q", got)
}
// Special characters
if got := pathescapeFn("test?query=1"); got != "test%3Fquery=1" {
t.Fatalf("expected 'test%%3Fquery=1', got %q", got)
}
})
}
func TestGetEmailConfig(t *testing.T) {
nm := NewNotificationManager("")
config := EmailConfig{
Enabled: true,
SMTPHost: "smtp.example.com",
SMTPPort: 587,
Username: "user@example.com",
Password: "secret",
From: "alerts@example.com",
To: []string{"admin@example.com", "ops@example.com"},
StartTLS: true,
}
nm.SetEmailConfig(config)
got := nm.GetEmailConfig()
if !got.Enabled {
t.Fatalf("expected enabled to be true")
}
if got.SMTPHost != "smtp.example.com" {
t.Fatalf("expected host 'smtp.example.com', got %q", got.SMTPHost)
}
if got.SMTPPort != 587 {
t.Fatalf("expected port 587, got %d", got.SMTPPort)
}
if got.Username != "user@example.com" {
t.Fatalf("expected username 'user@example.com', got %q", got.Username)
}
if got.From != "alerts@example.com" {
t.Fatalf("expected from 'alerts@example.com', got %q", got.From)
}
if len(got.To) != 2 {
t.Fatalf("expected 2 recipients, got %d", len(got.To))
}
if !got.StartTLS {
t.Fatalf("expected startTLS to be true")
}
}