diff --git a/internal/monitoring/helpers_test.go b/internal/monitoring/helpers_test.go index 4f966af..4f5d097 100644 --- a/internal/monitoring/helpers_test.go +++ b/internal/monitoring/helpers_test.go @@ -101,73 +101,7 @@ func TestGetNodeDisplayName(t *testing.T) { } } -func TestMergeNVMeTempsIntoDisks(t *testing.T) { - t.Parallel() - - original := []models.PhysicalDisk{ - {Node: "nodeA", Instance: "inst", DevPath: "/dev/nvme1n1", Type: "nvme", Temperature: 0}, - {Node: "nodeA", Instance: "inst", DevPath: "/dev/nvme0n1", Type: "NVME", Temperature: 0}, - {Node: "nodeB", Instance: "inst", DevPath: "/dev/sda", Type: "sata", Temperature: 45}, - } - - nodes := []models.Node{ - { - Name: "nodeA", - Temperature: &models.Temperature{ - Available: true, - NVMe: []models.NVMeTemp{ - {Device: "nvme0n1", Temp: 30.4}, - {Device: "nvme1n1", Temp: 31.6}, - }, - }, - }, - } - - merged := mergeNVMeTempsIntoDisks(original, nodes) - - if got, want := merged[0].Temperature, 32; got != want { - t.Fatalf("disk 0 temperature = %d, want %d", got, want) - } - if got, want := merged[1].Temperature, 30; got != want { - t.Fatalf("disk 1 temperature = %d, want %d", got, want) - } - if got, want := merged[2].Temperature, 45; got != want { - t.Fatalf("non-nvme disk temperature changed: got %d want %d", got, want) - } - if got := original[0].Temperature; got != 0 { - t.Fatalf("expected original slice unchanged, got %d", got) - } -} - -func TestMergeNVMeTempsIntoDisksClearsMissingOrInvalid(t *testing.T) { - t.Parallel() - - disks := []models.PhysicalDisk{ - {Node: "nodeA", Instance: "inst", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, - {Node: "nodeC", Instance: "inst", DevPath: "/dev/nvme1n1", Type: "nvme", Temperature: 0}, - } - - nodes := []models.Node{ - { - Name: "nodeA", - Temperature: &models.Temperature{ - Available: true, - NVMe: []models.NVMeTemp{ - {Device: "nvme0n1", Temp: math.NaN()}, - }, - }, - }, - } - - merged := mergeNVMeTempsIntoDisks(disks, nodes) - - if got := merged[0].Temperature; got != 0 { - t.Fatalf("expected NaN temp to reset to 0, got %d", got) - } - if got := merged[1].Temperature; got != 0 { - t.Fatalf("expected missing temps to reset to 0, got %d", got) - } -} +// TestMergeNVMeTempsIntoDisks moved to merge_temps_test.go func TestSafePercentage(t *testing.T) { t.Parallel() diff --git a/internal/monitoring/merge_temps_test.go b/internal/monitoring/merge_temps_test.go new file mode 100644 index 0000000..b145ee1 --- /dev/null +++ b/internal/monitoring/merge_temps_test.go @@ -0,0 +1,280 @@ +package monitoring + +import ( + "math" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestMergeNVMeTempsIntoDisks(t *testing.T) { + tests := []struct { + name string + disks []models.PhysicalDisk + nodes []models.Node + expected []models.PhysicalDisk + }{ + { + name: "empty disks returns empty", + disks: []models.PhysicalDisk{}, + nodes: []models.Node{{Name: "node1", Temperature: &models.Temperature{Available: true}}}, + expected: []models.PhysicalDisk{}, + }, + { + name: "empty nodes returns disks unchanged", + disks: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + nodes: []models.Node{}, + expected: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + }, + { + name: "nil temperature returns disks unchanged", + disks: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + nodes: []models.Node{{Name: "node1", Temperature: nil}}, + expected: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + }, + { + name: "temperature available false returns disks unchanged", + disks: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + nodes: []models.Node{{Name: "node1", Temperature: &models.Temperature{Available: false}}}, + expected: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + }, + { + name: "no SMART or NVMe temps returns disks unchanged", + disks: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + nodes: []models.Node{{Name: "node1", Temperature: &models.Temperature{Available: true, SMART: nil, NVMe: nil}}}, + expected: []models.PhysicalDisk{{Node: "node1", DevPath: "/dev/sda", Temperature: 0}}, + }, + { + name: "SMART temperature matched by WWN", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "5000c5001234abcd", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sda", WWN: "5000C5001234ABCD", Temperature: 42}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "5000c5001234abcd", Temperature: 42}, + }, + }, + { + name: "SMART temperature matched by serial case insensitive", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sdb", Serial: "WD-ABC123", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sdb", Serial: "wd-abc123", Temperature: 38}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sdb", Serial: "WD-ABC123", Temperature: 38}, + }, + }, + { + name: "SMART temperature matched by device path", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sdc", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sdc", Temperature: 35}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sdc", Temperature: 35}, + }, + }, + { + name: "NVMe legacy fallback when no SMART match", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + NVMe: []models.NVMeTemp{ + {Device: "nvme0", Temp: 45.5}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 46}, + }, + }, + { + name: "temperature zero is not applied", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "5000c500", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sda", WWN: "5000c500", Temperature: 0}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "5000c500", Temperature: 0}, + }, + }, + { + name: "standby skipped is not applied", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "5000c500", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sda", WWN: "5000c500", Temperature: 40, StandbySkipped: true}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "5000c500", Temperature: 0}, + }, + }, + { + name: "multiple nodes multiple disks with various matches", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "wwn1", Temperature: 0}, + {Node: "node1", DevPath: "/dev/sdb", Serial: "SERIAL2", Temperature: 0}, + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, + {Node: "node2", DevPath: "/dev/sda", Temperature: 0}, + {Node: "node2", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, + {Node: "node2", DevPath: "/dev/nvme1n1", Type: "nvme", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sda", WWN: "WWN1", Temperature: 41}, + {Device: "/dev/sdb", Serial: "serial2", Temperature: 42}, + }, + NVMe: []models.NVMeTemp{ + {Device: "nvme0", Temp: 50.2}, + }, + }, + }, + { + Name: "node2", + Temperature: &models.Temperature{ + Available: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sda", Temperature: 43}, + }, + NVMe: []models.NVMeTemp{ + {Device: "nvme0", Temp: 51.8}, + {Device: "nvme1", Temp: 52.3}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/sda", WWN: "wwn1", Temperature: 41}, + {Node: "node1", DevPath: "/dev/sdb", Serial: "SERIAL2", Temperature: 42}, + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 50}, + {Node: "node2", DevPath: "/dev/sda", Temperature: 43}, + {Node: "node2", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 52}, + {Node: "node2", DevPath: "/dev/nvme1n1", Type: "nvme", Temperature: 52}, + }, + }, + { + name: "NaN temperature is not applied", + disks: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, + }, + nodes: []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + NVMe: []models.NVMeTemp{ + {Device: "nvme0", Temp: math.NaN()}, + }, + }, + }, + }, + expected: []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeNVMeTempsIntoDisks(tt.disks, tt.nodes) + + if len(result) != len(tt.expected) { + t.Fatalf("got %d disks, want %d", len(result), len(tt.expected)) + } + + for i := range result { + if result[i].Temperature != tt.expected[i].Temperature { + t.Errorf("disk[%d] %s: got temperature %d, want %d", + i, result[i].DevPath, result[i].Temperature, tt.expected[i].Temperature) + } + } + }) + } +} + +func TestMergeNVMeTempsIntoDisks_OriginalSliceUnchanged(t *testing.T) { + original := []models.PhysicalDisk{ + {Node: "node1", DevPath: "/dev/nvme0n1", Type: "nvme", Temperature: 0}, + } + + nodes := []models.Node{ + { + Name: "node1", + Temperature: &models.Temperature{ + Available: true, + NVMe: []models.NVMeTemp{ + {Device: "nvme0", Temp: 45.0}, + }, + }, + }, + } + + result := mergeNVMeTempsIntoDisks(original, nodes) + + if result[0].Temperature != 45 { + t.Errorf("merged disk temperature = %d, want 45", result[0].Temperature) + } + + if original[0].Temperature != 0 { + t.Errorf("original disk temperature was modified: got %d, want 0", original[0].Temperature) + } +} diff --git a/internal/monitoring/metrics_test.go b/internal/monitoring/metrics_test.go new file mode 100644 index 0000000..4c853f2 --- /dev/null +++ b/internal/monitoring/metrics_test.go @@ -0,0 +1,347 @@ +package monitoring + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +// newTestPollMetrics creates a PollMetrics instance with an isolated registry for testing. +func newTestPollMetrics(t *testing.T) *PollMetrics { + t.Helper() + + reg := prometheus.NewRegistry() + + pm := &PollMetrics{ + schedulerQueueReady: prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "pulse", + Subsystem: "scheduler", + Name: "queue_due_soon", + Help: "Number of tasks due to run within the immediate window.", + }, + ), + schedulerQueueDepthByType: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "pulse", + Subsystem: "scheduler", + Name: "queue_depth", + Help: "Current scheduler queue depth partitioned by instance type.", + }, + []string{"instance_type"}, + ), + schedulerDeadLetterDepth: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "pulse", + Subsystem: "scheduler", + Name: "dead_letter_depth", + Help: "Number of tasks currently parked in the dead-letter queue per instance.", + }, + []string{"instance_type", "instance"}, + ), + lastQueueTypeKeys: make(map[string]struct{}), + lastDLQKeys: make(map[string]struct{}), + } + + reg.MustRegister( + pm.schedulerQueueReady, + pm.schedulerQueueDepthByType, + pm.schedulerDeadLetterDepth, + ) + + return pm +} + +// getGaugeValue returns the value of a prometheus gauge. +func getGaugeValue(g prometheus.Gauge) float64 { + m := &dto.Metric{} + if err := g.Write(m); err != nil { + return 0 + } + return m.GetGauge().GetValue() +} + +// getGaugeVecValue returns the value for specific labels from a GaugeVec. +func getGaugeVecValue(gv *prometheus.GaugeVec, labels ...string) float64 { + m := &dto.Metric{} + gauge, err := gv.GetMetricWithLabelValues(labels...) + if err != nil { + return 0 + } + if err := gauge.Write(m); err != nil { + return 0 + } + return m.GetGauge().GetValue() +} + +func TestUpdateQueueSnapshot_NilPollMetrics(t *testing.T) { + t.Parallel() + + var pm *PollMetrics + // Should not panic + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 5, + PerType: map[string]int{"pve": 10}, + }) +} + +func TestUpdateQueueSnapshot_SetsDueWithinSeconds(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 42, + PerType: map[string]int{}, + }) + + got := getGaugeValue(pm.schedulerQueueReady) + if got != 42 { + t.Fatalf("schedulerQueueReady = %v, want 42", got) + } +} + +func TestUpdateQueueSnapshot_UpdatesPerTypeQueueDepth(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 0, + PerType: map[string]int{ + "pve": 10, + "pbs": 5, + "pmg": 3, + }, + }) + + cases := []struct { + instanceType string + want float64 + }{ + {"pve", 10}, + {"pbs", 5}, + {"pmg", 3}, + } + + for _, tc := range cases { + got := getGaugeVecValue(pm.schedulerQueueDepthByType, tc.instanceType) + if got != tc.want { + t.Errorf("queue_depth{instance_type=%q} = %v, want %v", tc.instanceType, got, tc.want) + } + } +} + +func TestUpdateQueueSnapshot_ClearsStaleTypeKeys(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + // First snapshot with pve and pbs + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 0, + PerType: map[string]int{ + "pve": 10, + "pbs": 5, + }, + }) + + // Verify initial values + if got := getGaugeVecValue(pm.schedulerQueueDepthByType, "pve"); got != 10 { + t.Fatalf("initial pve = %v, want 10", got) + } + if got := getGaugeVecValue(pm.schedulerQueueDepthByType, "pbs"); got != 5 { + t.Fatalf("initial pbs = %v, want 5", got) + } + + // Second snapshot with only pve (pbs should be cleared) + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 0, + PerType: map[string]int{ + "pve": 8, + }, + }) + + if got := getGaugeVecValue(pm.schedulerQueueDepthByType, "pve"); got != 8 { + t.Errorf("updated pve = %v, want 8", got) + } + if got := getGaugeVecValue(pm.schedulerQueueDepthByType, "pbs"); got != 0 { + t.Errorf("stale pbs should be 0, got %v", got) + } +} + +func TestUpdateQueueSnapshot_EmptySnapshot(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + // First add some data + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 10, + PerType: map[string]int{ + "pve": 5, + }, + }) + + // Then clear with empty snapshot + pm.UpdateQueueSnapshot(QueueSnapshot{ + DueWithinSeconds: 0, + PerType: map[string]int{}, + }) + + if got := getGaugeValue(pm.schedulerQueueReady); got != 0 { + t.Errorf("schedulerQueueReady = %v, want 0", got) + } + if got := getGaugeVecValue(pm.schedulerQueueDepthByType, "pve"); got != 0 { + t.Errorf("pve should be cleared to 0, got %v", got) + } +} + +func TestUpdateDeadLetterCounts_NilPollMetrics(t *testing.T) { + t.Parallel() + + var pm *PollMetrics + // Should not panic + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "pve1"}, + }) +} + +func TestUpdateDeadLetterCounts_EmptyClearsPrevious(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + // First add some tasks + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "pve1"}, + {Type: "pbs", Instance: "pbs1"}, + }) + + // Verify they were set + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve1"); got != 1 { + t.Fatalf("initial pve/pve1 = %v, want 1", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pbs", "pbs1"); got != 1 { + t.Fatalf("initial pbs/pbs1 = %v, want 1", got) + } + + // Clear with empty slice + pm.UpdateDeadLetterCounts([]DeadLetterTask{}) + + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve1"); got != 0 { + t.Errorf("pve/pve1 should be cleared to 0, got %v", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pbs", "pbs1"); got != 0 { + t.Errorf("pbs/pbs1 should be cleared to 0, got %v", got) + } +} + +func TestUpdateDeadLetterCounts_SingleTask(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "my-pve-instance"}, + }) + + got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "my-pve-instance") + if got != 1 { + t.Fatalf("dead_letter_depth{pve,my-pve-instance} = %v, want 1", got) + } +} + +func TestUpdateDeadLetterCounts_AggregatesSameTypeInstance(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "pve1"}, + {Type: "pve", Instance: "pve1"}, + {Type: "pve", Instance: "pve1"}, + {Type: "pbs", Instance: "pbs1"}, + {Type: "pbs", Instance: "pbs1"}, + }) + + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve1"); got != 3 { + t.Errorf("pve/pve1 = %v, want 3", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pbs", "pbs1"); got != 2 { + t.Errorf("pbs/pbs1 = %v, want 2", got) + } +} + +func TestUpdateDeadLetterCounts_ClearsStaleKeys(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + // First update with pve1 and pbs1 + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "pve1"}, + {Type: "pbs", Instance: "pbs1"}, + }) + + // Verify initial values + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve1"); got != 1 { + t.Fatalf("initial pve/pve1 = %v, want 1", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pbs", "pbs1"); got != 1 { + t.Fatalf("initial pbs/pbs1 = %v, want 1", got) + } + + // Second update with only pve1 (pbs1 should be cleared) + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "pve1"}, + {Type: "pve", Instance: "pve1"}, + }) + + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve1"); got != 2 { + t.Errorf("updated pve/pve1 = %v, want 2", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pbs", "pbs1"); got != 0 { + t.Errorf("stale pbs/pbs1 should be 0, got %v", got) + } +} + +func TestUpdateDeadLetterCounts_NormalizesEmptyLabels(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + // Empty Type and Instance should normalize to "unknown" + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "", Instance: ""}, + {Type: " ", Instance: " "}, + }) + + got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "unknown", "unknown") + if got != 2 { + t.Fatalf("empty labels should normalize to unknown, got count %v, want 2", got) + } +} + +func TestUpdateDeadLetterCounts_MultipleInstancesSameType(t *testing.T) { + t.Parallel() + + pm := newTestPollMetrics(t) + + pm.UpdateDeadLetterCounts([]DeadLetterTask{ + {Type: "pve", Instance: "pve1"}, + {Type: "pve", Instance: "pve2"}, + {Type: "pve", Instance: "pve3"}, + }) + + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve1"); got != 1 { + t.Errorf("pve/pve1 = %v, want 1", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve2"); got != 1 { + t.Errorf("pve/pve2 = %v, want 1", got) + } + if got := getGaugeVecValue(pm.schedulerDeadLetterDepth, "pve", "pve3"); got != 1 { + t.Errorf("pve/pve3 = %v, want 1", got) + } +} diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 5ab1042..139375a 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -1187,6 +1187,155 @@ func TestWebhookDelete(t *testing.T) { }) } +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("")