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%
This commit is contained in:
rcourtman 2025-12-01 18:14:43 +00:00
parent 0803951854
commit b4fb5d2a6a
4 changed files with 777 additions and 67 deletions

View file

@ -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()

View file

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

View file

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

View file

@ -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("")