mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
15657d9a0a
Back-port v5 fix a4834ed80 to v6. hostDisplayName now appends
'(Host Agent)' (or returns 'Host Agent' for an unnamed agent) when the
host is linked to a Proxmox node/VM/container, and the disk-temperature
resource name uses hostDisplayName(host) instead of host.DisplayName.
Without this, a host agent running alongside the Proxmox node/VM it is
linked to produced alerts with an identical name, making them
indistinguishable. Adapted to v6's 'Agent' fallback. Adds test cases.
2617 lines
61 KiB
Go
2617 lines
61 KiB
Go
package alerts
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
// TestSanitizeAlertKey tests the sanitizeAlertKey function
|
|
func TestSanitizeAlertKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
// Basic cases
|
|
{
|
|
name: "empty string returns empty",
|
|
input: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "whitespace only returns empty",
|
|
input: " ",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "simple lowercase passes through",
|
|
input: "disk",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "uppercase converted to lowercase",
|
|
input: "DISK",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "mixed case normalized",
|
|
input: "MyDisk",
|
|
want: "mydisk",
|
|
},
|
|
|
|
// Root handling
|
|
{
|
|
name: "single slash becomes root",
|
|
input: "/",
|
|
want: "root",
|
|
},
|
|
{
|
|
name: "slashes trimmed",
|
|
input: "/disk/",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "leading slashes trimmed",
|
|
input: "/mnt/data",
|
|
want: "mnt-data",
|
|
},
|
|
|
|
// Special character handling
|
|
{
|
|
name: "spaces become dashes",
|
|
input: "my disk",
|
|
want: "my-disk",
|
|
},
|
|
{
|
|
name: "multiple spaces become single dash",
|
|
input: "my disk",
|
|
want: "my-disk",
|
|
},
|
|
{
|
|
name: "underscores become dashes",
|
|
input: "my_disk",
|
|
want: "my-disk",
|
|
},
|
|
{
|
|
name: "backslashes handled",
|
|
input: "C:\\Users\\Data",
|
|
want: "c-users-data",
|
|
},
|
|
{
|
|
name: "dots preserved",
|
|
input: "disk.local",
|
|
want: "disk.local",
|
|
},
|
|
{
|
|
name: "numbers preserved",
|
|
input: "disk123",
|
|
want: "disk123",
|
|
},
|
|
{
|
|
name: "alphanumeric with dots",
|
|
input: "nvme0n1p1",
|
|
want: "nvme0n1p1",
|
|
},
|
|
|
|
// Edge cases
|
|
{
|
|
name: "only slashes and backslashes becomes root",
|
|
input: "//\\\\",
|
|
want: "root",
|
|
},
|
|
{
|
|
name: "only special chars becomes disk",
|
|
input: "@#$%",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "trailing dashes trimmed",
|
|
input: "disk--",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "trailing dots trimmed",
|
|
input: "disk..",
|
|
want: "disk",
|
|
},
|
|
{
|
|
name: "leading and trailing trimmed",
|
|
input: "--disk--",
|
|
want: "disk",
|
|
},
|
|
|
|
// Real-world examples
|
|
{
|
|
name: "linux mount path",
|
|
input: "/mnt/storage/backup",
|
|
want: "mnt-storage-backup",
|
|
},
|
|
{
|
|
name: "linux device path",
|
|
input: "/dev/sda1",
|
|
want: "dev-sda1",
|
|
},
|
|
{
|
|
name: "nvme device",
|
|
input: "/dev/nvme0n1",
|
|
want: "dev-nvme0n1",
|
|
},
|
|
{
|
|
name: "windows drive letter",
|
|
input: "C:",
|
|
want: "c",
|
|
},
|
|
{
|
|
name: "docker volume name",
|
|
input: "my_app_data",
|
|
want: "my-app-data",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeAlertKey(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeAlertKey(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAbs tests the abs function for float64
|
|
func TestAbs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input float64
|
|
want float64
|
|
}{
|
|
{
|
|
name: "positive returns unchanged",
|
|
input: 5.5,
|
|
want: 5.5,
|
|
},
|
|
{
|
|
name: "negative becomes positive",
|
|
input: -5.5,
|
|
want: 5.5,
|
|
},
|
|
{
|
|
name: "zero returns zero",
|
|
input: 0,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "small positive",
|
|
input: 0.001,
|
|
want: 0.001,
|
|
},
|
|
{
|
|
name: "small negative",
|
|
input: -0.001,
|
|
want: 0.001,
|
|
},
|
|
{
|
|
name: "large positive",
|
|
input: 1e10,
|
|
want: 1e10,
|
|
},
|
|
{
|
|
name: "large negative",
|
|
input: -1e10,
|
|
want: 1e10,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := abs(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("abs(%v) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsQueueOutlier tests the isQueueOutlier function
|
|
func TestIsQueueOutlier(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value int
|
|
median int
|
|
want bool
|
|
}{
|
|
// Zero median cases
|
|
{
|
|
name: "zero median with zero value is not outlier",
|
|
value: 0,
|
|
median: 0,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "zero median with positive value is outlier",
|
|
value: 1,
|
|
median: 0,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "zero median with large value is outlier",
|
|
value: 100,
|
|
median: 0,
|
|
want: true,
|
|
},
|
|
|
|
// Normal cases (threshold is 40% above median)
|
|
{
|
|
name: "value equal to median is not outlier",
|
|
value: 100,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value 20% above median is not outlier",
|
|
value: 120,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value 40% above median is not outlier (boundary)",
|
|
value: 140,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value 41% above median is outlier",
|
|
value: 141,
|
|
median: 100,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "value 50% above median is outlier",
|
|
value: 150,
|
|
median: 100,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "value 100% above median is outlier",
|
|
value: 200,
|
|
median: 100,
|
|
want: true,
|
|
},
|
|
|
|
// Value below median
|
|
{
|
|
name: "value below median is not outlier",
|
|
value: 50,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "value at zero with nonzero median is not outlier",
|
|
value: 0,
|
|
median: 100,
|
|
want: false,
|
|
},
|
|
|
|
// Small numbers
|
|
{
|
|
name: "small median value at boundary",
|
|
value: 7,
|
|
median: 5,
|
|
want: false, // 7/5 = 1.4 = 40%, at boundary
|
|
},
|
|
{
|
|
name: "small median value just over",
|
|
value: 8,
|
|
median: 5,
|
|
want: true, // 8/5 = 1.6 = 60% above
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := isQueueOutlier(tc.value, tc.median)
|
|
if got != tc.want {
|
|
t.Errorf("isQueueOutlier(%d, %d) = %v, want %v", tc.value, tc.median, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestScaleThreshold tests the scaleThreshold function
|
|
func TestScaleThreshold(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
threshold int
|
|
scaleFactor float64
|
|
want int
|
|
}{
|
|
// Zero threshold
|
|
{
|
|
name: "zero threshold returns zero",
|
|
threshold: 0,
|
|
scaleFactor: 2.0,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "negative threshold returns zero",
|
|
threshold: -5,
|
|
scaleFactor: 2.0,
|
|
want: 0,
|
|
},
|
|
|
|
// Normal scaling
|
|
{
|
|
name: "scale by 1.0 returns same",
|
|
threshold: 100,
|
|
scaleFactor: 1.0,
|
|
want: 100,
|
|
},
|
|
{
|
|
name: "scale by 2.0 doubles",
|
|
threshold: 100,
|
|
scaleFactor: 2.0,
|
|
want: 200,
|
|
},
|
|
{
|
|
name: "scale by 0.5 halves",
|
|
threshold: 100,
|
|
scaleFactor: 0.5,
|
|
want: 50,
|
|
},
|
|
{
|
|
name: "scale by 1.5",
|
|
threshold: 100,
|
|
scaleFactor: 1.5,
|
|
want: 150,
|
|
},
|
|
|
|
// Ceiling behavior
|
|
{
|
|
name: "ceiling applied for fractional result",
|
|
threshold: 10,
|
|
scaleFactor: 1.1,
|
|
want: 11, // 10 * 1.1 = 11.0 (exact)
|
|
},
|
|
{
|
|
name: "ceiling rounds up",
|
|
threshold: 10,
|
|
scaleFactor: 0.33,
|
|
want: 4, // 10 * 0.33 = 3.3 -> ceil = 4
|
|
},
|
|
{
|
|
name: "small threshold with small factor",
|
|
threshold: 3,
|
|
scaleFactor: 0.5,
|
|
want: 2, // 3 * 0.5 = 1.5 -> ceil = 2
|
|
},
|
|
|
|
// Minimum value of 1
|
|
{
|
|
name: "very small result floors to 1",
|
|
threshold: 1,
|
|
scaleFactor: 0.1,
|
|
want: 1, // 1 * 0.1 = 0.1 -> ceil = 1, min = 1
|
|
},
|
|
{
|
|
name: "result near zero floors to 1",
|
|
threshold: 5,
|
|
scaleFactor: 0.001,
|
|
want: 1, // 5 * 0.001 = 0.005 -> ceil = 1
|
|
},
|
|
|
|
// Edge cases
|
|
{
|
|
name: "large threshold",
|
|
threshold: 10000,
|
|
scaleFactor: 10.0,
|
|
want: 100000,
|
|
},
|
|
{
|
|
name: "zero scale factor gives 1",
|
|
threshold: 100,
|
|
scaleFactor: 0.0,
|
|
want: 1, // 100 * 0 = 0 -> ceil(0) = 0, min = 1
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := scaleThreshold(tc.threshold, tc.scaleFactor)
|
|
if got != tc.want {
|
|
t.Errorf("scaleThreshold(%d, %v) = %d, want %d", tc.threshold, tc.scaleFactor, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCalculateMedianInt tests the calculateMedianInt function
|
|
func TestCalculateMedianInt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
values []int
|
|
want int
|
|
}{
|
|
// Empty and single element
|
|
{
|
|
name: "empty slice returns 0",
|
|
values: []int{},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "nil slice returns 0",
|
|
values: nil,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "single element returns that element",
|
|
values: []int{5},
|
|
want: 5,
|
|
},
|
|
{
|
|
name: "single zero returns 0",
|
|
values: []int{0},
|
|
want: 0,
|
|
},
|
|
|
|
// Odd number of elements
|
|
{
|
|
name: "three elements returns middle",
|
|
values: []int{1, 2, 3},
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "three unsorted elements",
|
|
values: []int{3, 1, 2},
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "five elements returns middle",
|
|
values: []int{1, 2, 3, 4, 5},
|
|
want: 3,
|
|
},
|
|
{
|
|
name: "five unsorted elements",
|
|
values: []int{5, 1, 3, 2, 4},
|
|
want: 3,
|
|
},
|
|
|
|
// Even number of elements
|
|
{
|
|
name: "two elements returns average",
|
|
values: []int{2, 4},
|
|
want: 3, // (2 + 4) / 2 = 3
|
|
},
|
|
{
|
|
name: "four elements returns average of middle two",
|
|
values: []int{1, 2, 3, 4},
|
|
want: 2, // (2 + 3) / 2 = 2 (integer division)
|
|
},
|
|
{
|
|
name: "four unsorted elements",
|
|
values: []int{4, 1, 3, 2},
|
|
want: 2, // sorted: 1,2,3,4 -> (2+3)/2 = 2
|
|
},
|
|
{
|
|
name: "six elements",
|
|
values: []int{1, 2, 3, 4, 5, 6},
|
|
want: 3, // (3 + 4) / 2 = 3 (integer division)
|
|
},
|
|
|
|
// Duplicates
|
|
{
|
|
name: "all same values",
|
|
values: []int{5, 5, 5, 5, 5},
|
|
want: 5,
|
|
},
|
|
{
|
|
name: "some duplicates odd count",
|
|
values: []int{1, 2, 2, 3, 3},
|
|
want: 2,
|
|
},
|
|
{
|
|
name: "some duplicates even count",
|
|
values: []int{1, 1, 3, 3},
|
|
want: 2, // (1 + 3) / 2 = 2
|
|
},
|
|
|
|
// Negative numbers
|
|
{
|
|
name: "negative numbers",
|
|
values: []int{-5, -3, -1},
|
|
want: -3,
|
|
},
|
|
{
|
|
name: "mixed positive and negative",
|
|
values: []int{-5, 0, 5},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "mixed even count",
|
|
values: []int{-4, -2, 2, 4},
|
|
want: 0, // (-2 + 2) / 2 = 0
|
|
},
|
|
|
|
// Large values
|
|
{
|
|
name: "large values",
|
|
values: []int{1000000, 2000000, 3000000},
|
|
want: 2000000,
|
|
},
|
|
|
|
// Edge case for integer division
|
|
{
|
|
name: "average truncates down",
|
|
values: []int{1, 4},
|
|
want: 2, // (1 + 4) / 2 = 2 (not 2.5)
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Make a copy to ensure original isn't modified
|
|
var input []int
|
|
if tc.values != nil {
|
|
input = make([]int, len(tc.values))
|
|
copy(input, tc.values)
|
|
}
|
|
|
|
got := calculateMedianInt(input)
|
|
if got != tc.want {
|
|
t.Errorf("calculateMedianInt(%v) = %d, want %d", tc.values, got, tc.want)
|
|
}
|
|
|
|
// Verify original slice wasn't modified (if non-nil)
|
|
if tc.values != nil && input != nil {
|
|
for i := range input {
|
|
if input[i] != tc.values[i] {
|
|
t.Errorf("calculateMedianInt modified input slice")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCalculateMedianInt_DoesNotModifyInput verifies the input slice is copied
|
|
func TestCalculateMedianInt_DoesNotModifyInput(t *testing.T) {
|
|
input := []int{5, 1, 3, 2, 4}
|
|
original := make([]int, len(input))
|
|
copy(original, input)
|
|
|
|
_ = calculateMedianInt(input)
|
|
|
|
for i := range input {
|
|
if input[i] != original[i] {
|
|
t.Errorf("calculateMedianInt modified input: got %v, original was %v", input, original)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHostResourceID tests the hostResourceID function
|
|
func TestHostResourceID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hostID string
|
|
want string
|
|
}{
|
|
{
|
|
name: "normal host ID",
|
|
hostID: "host-123",
|
|
want: "agent:host-123",
|
|
},
|
|
{
|
|
name: "empty string returns unknown",
|
|
hostID: "",
|
|
want: "agent:unknown",
|
|
},
|
|
{
|
|
name: "whitespace only returns unknown",
|
|
hostID: " ",
|
|
want: "agent:unknown",
|
|
},
|
|
{
|
|
name: "whitespace is trimmed",
|
|
hostID: " host-456 ",
|
|
want: "agent:host-456",
|
|
},
|
|
{
|
|
name: "UUID format",
|
|
hostID: "550e8400-e29b-41d4-a716-446655440000",
|
|
want: "agent:550e8400-e29b-41d4-a716-446655440000",
|
|
},
|
|
{
|
|
name: "simple hostname",
|
|
hostID: "server1",
|
|
want: "agent:server1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hostResourceID(tc.hostID)
|
|
if got != tc.want {
|
|
t.Errorf("hostResourceID(%q) = %q, want %q", tc.hostID, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHostDisplayName tests the hostDisplayName function
|
|
func TestHostDisplayName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host models.Host
|
|
want string
|
|
}{
|
|
{
|
|
name: "display name preferred",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "My Server",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "My Server",
|
|
},
|
|
{
|
|
name: "hostname when no display name",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "server.local",
|
|
},
|
|
{
|
|
name: "ID when no display name or hostname",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "",
|
|
Hostname: "",
|
|
},
|
|
want: "id-123",
|
|
},
|
|
{
|
|
name: "fallback to Agent literal",
|
|
host: models.Host{
|
|
ID: "",
|
|
DisplayName: "",
|
|
Hostname: "",
|
|
},
|
|
want: "Agent",
|
|
},
|
|
{
|
|
name: "whitespace display name ignored",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: " ",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "server.local",
|
|
},
|
|
{
|
|
name: "whitespace hostname ignored",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "",
|
|
Hostname: " ",
|
|
},
|
|
want: "id-123",
|
|
},
|
|
{
|
|
name: "display name with whitespace trimmed",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: " Server Name ",
|
|
Hostname: "server.local",
|
|
},
|
|
want: "Server Name",
|
|
},
|
|
{
|
|
name: "linked host agent display name is qualified",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "Hamster",
|
|
LinkedVMID: "Main:node1:101",
|
|
},
|
|
want: "Hamster (Host Agent)",
|
|
},
|
|
{
|
|
name: "linked host agent hostname fallback is qualified",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
Hostname: "proxmoxn3",
|
|
LinkedContainerID: "Main:node1:102",
|
|
},
|
|
want: "proxmoxn3 (Host Agent)",
|
|
},
|
|
{
|
|
name: "existing host agent suffix is not duplicated",
|
|
host: models.Host{
|
|
ID: "id-123",
|
|
DisplayName: "Hamster (Host Agent)",
|
|
LinkedNodeID: "Main-node1",
|
|
},
|
|
want: "Hamster (Host Agent)",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hostDisplayName(tc.host)
|
|
if got != tc.want {
|
|
t.Errorf("hostDisplayName() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHostInstanceName tests the hostInstanceName function
|
|
func TestHostInstanceName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host models.Host
|
|
want string
|
|
}{
|
|
{
|
|
name: "platform preferred",
|
|
host: models.Host{
|
|
Platform: "linux",
|
|
OSName: "Ubuntu 22.04",
|
|
},
|
|
want: "linux",
|
|
},
|
|
{
|
|
name: "os name when no platform",
|
|
host: models.Host{
|
|
Platform: "",
|
|
OSName: "Ubuntu 22.04",
|
|
},
|
|
want: "Ubuntu 22.04",
|
|
},
|
|
{
|
|
name: "fallback to Agent",
|
|
host: models.Host{
|
|
Platform: "",
|
|
OSName: "",
|
|
},
|
|
want: "Agent",
|
|
},
|
|
{
|
|
name: "whitespace platform ignored",
|
|
host: models.Host{
|
|
Platform: " ",
|
|
OSName: "Windows Server",
|
|
},
|
|
want: "Windows Server",
|
|
},
|
|
{
|
|
name: "whitespace os name ignored",
|
|
host: models.Host{
|
|
Platform: "",
|
|
OSName: " ",
|
|
},
|
|
want: "Agent",
|
|
},
|
|
{
|
|
name: "platform with whitespace trimmed",
|
|
host: models.Host{
|
|
Platform: " darwin ",
|
|
OSName: "macOS",
|
|
},
|
|
want: "darwin",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := hostInstanceName(tc.host)
|
|
if got != tc.want {
|
|
t.Errorf("hostInstanceName() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSanitizeHostComponent tests the sanitizeHostComponent function
|
|
func TestSanitizeHostComponent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
// Empty/whitespace
|
|
{
|
|
name: "empty returns unknown",
|
|
input: "",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "whitespace only returns unknown",
|
|
input: " ",
|
|
want: "unknown",
|
|
},
|
|
|
|
// Basic strings
|
|
{
|
|
name: "lowercase passes through",
|
|
input: "myhost",
|
|
want: "myhost",
|
|
},
|
|
{
|
|
name: "uppercase converted to lowercase",
|
|
input: "MYHOST",
|
|
want: "myhost",
|
|
},
|
|
{
|
|
name: "mixed case normalized",
|
|
input: "MyHost",
|
|
want: "myhost",
|
|
},
|
|
{
|
|
name: "numbers preserved",
|
|
input: "host123",
|
|
want: "host123",
|
|
},
|
|
|
|
// Special characters become hyphens
|
|
{
|
|
name: "spaces become hyphen",
|
|
input: "my host",
|
|
want: "my-host",
|
|
},
|
|
{
|
|
name: "multiple spaces become single hyphen",
|
|
input: "my host",
|
|
want: "my-host",
|
|
},
|
|
{
|
|
name: "underscores become hyphen",
|
|
input: "my_host",
|
|
want: "my-host",
|
|
},
|
|
{
|
|
name: "slashes become hyphen",
|
|
input: "mnt/data",
|
|
want: "mnt-data",
|
|
},
|
|
{
|
|
name: "dots become hyphen",
|
|
input: "host.local",
|
|
want: "host-local",
|
|
},
|
|
{
|
|
name: "mixed special chars",
|
|
input: "host.local/data_01",
|
|
want: "host-local-data-01",
|
|
},
|
|
|
|
// Trimming leading/trailing hyphens
|
|
{
|
|
name: "leading special chars trimmed",
|
|
input: "--host",
|
|
want: "host",
|
|
},
|
|
{
|
|
name: "trailing special chars trimmed",
|
|
input: "host--",
|
|
want: "host",
|
|
},
|
|
{
|
|
name: "both ends trimmed",
|
|
input: "/host/",
|
|
want: "host",
|
|
},
|
|
|
|
// Only special chars
|
|
{
|
|
name: "only special chars returns unknown",
|
|
input: "@#$%",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "only hyphens returns unknown",
|
|
input: "---",
|
|
want: "unknown",
|
|
},
|
|
|
|
// Real-world examples
|
|
{
|
|
name: "linux mount path",
|
|
input: "/mnt/storage",
|
|
want: "mnt-storage",
|
|
},
|
|
{
|
|
name: "device path",
|
|
input: "/dev/sda1",
|
|
want: "dev-sda1",
|
|
},
|
|
{
|
|
name: "nvme device",
|
|
input: "nvme0n1p1",
|
|
want: "nvme0n1p1",
|
|
},
|
|
{
|
|
name: "IP address",
|
|
input: "192.168.1.1",
|
|
want: "192-168-1-1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeHostComponent(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeHostComponent(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSanitizeRAIDDevice tests the sanitizeRAIDDevice function
|
|
func TestSanitizeRAIDDevice(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "with dev prefix",
|
|
input: "/dev/md0",
|
|
want: "md0",
|
|
},
|
|
{
|
|
name: "without dev prefix",
|
|
input: "md0",
|
|
want: "md0",
|
|
},
|
|
{
|
|
name: "nvme device",
|
|
input: "/dev/nvme0n1",
|
|
want: "nvme0n1",
|
|
},
|
|
{
|
|
name: "sda device",
|
|
input: "/dev/sda",
|
|
want: "sda",
|
|
},
|
|
{
|
|
name: "empty returns unknown",
|
|
input: "",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "only dev prefix",
|
|
input: "/dev/",
|
|
want: "unknown",
|
|
},
|
|
{
|
|
name: "md device with partition",
|
|
input: "/dev/md127p1",
|
|
want: "md127p1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeRAIDDevice(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeRAIDDevice(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHostDiskResourceID tests the hostDiskResourceID function
|
|
func TestHostDiskResourceID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host models.Host
|
|
disk models.Disk
|
|
wantID string
|
|
wantNamePart string // Part of the name to check
|
|
}{
|
|
{
|
|
name: "mountpoint preferred",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "/mnt/data",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "agent:host-123/disk:mnt-data",
|
|
wantNamePart: "/mnt/data",
|
|
},
|
|
{
|
|
name: "device when no mountpoint",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "agent:host-123/disk:dev-sda1",
|
|
wantNamePart: "/dev/sda1",
|
|
},
|
|
{
|
|
name: "fallback to disk literal",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "",
|
|
Device: "",
|
|
},
|
|
wantID: "agent:host-123/disk:disk",
|
|
wantNamePart: "disk",
|
|
},
|
|
{
|
|
name: "root mount sanitizes to unknown",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: "/",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "agent:host-123/disk:unknown",
|
|
wantNamePart: "/",
|
|
},
|
|
{
|
|
name: "whitespace mountpoint uses device",
|
|
host: models.Host{ID: "host-123"},
|
|
disk: models.Disk{
|
|
Mountpoint: " ",
|
|
Device: "/dev/sda1",
|
|
},
|
|
wantID: "agent:host-123/disk:dev-sda1",
|
|
wantNamePart: "/dev/sda1",
|
|
},
|
|
{
|
|
name: "includes host display name",
|
|
host: models.Host{
|
|
ID: "host-123",
|
|
DisplayName: "My Server",
|
|
},
|
|
disk: models.Disk{
|
|
Mountpoint: "/data",
|
|
},
|
|
wantID: "agent:host-123/disk:data",
|
|
wantNamePart: "My Server",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotID, gotName := hostDiskResourceID(tc.host, tc.disk)
|
|
if gotID != tc.wantID {
|
|
t.Errorf("hostDiskResourceID() ID = %q, want %q", gotID, tc.wantID)
|
|
}
|
|
if !strings.Contains(gotName, tc.wantNamePart) {
|
|
t.Errorf("hostDiskResourceID() Name = %q, want to contain %q", gotName, tc.wantNamePart)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsMonitorOnlyAlert tests the isMonitorOnlyAlert function
|
|
func TestIsMonitorOnlyAlert(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
alert *Alert
|
|
want bool
|
|
}{
|
|
{
|
|
name: "nil alert returns false",
|
|
alert: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "nil metadata returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: nil,
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty metadata returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "no monitorOnly key returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"other": "value"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly bool true returns true",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": true},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly bool false returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": false},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'true' returns true",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "true"},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'TRUE' returns true (case insensitive)",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "TRUE"},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'True' returns true (case insensitive)",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "True"},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'false' returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "false"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly string 'yes' returns false (not 'true')",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": "yes"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly int value returns false (not bool or string)",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": 1},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "monitorOnly nil value returns false",
|
|
alert: &Alert{
|
|
ID: "test-1",
|
|
Metadata: map[string]interface{}{"monitorOnly": nil},
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := isMonitorOnlyAlert(tc.alert)
|
|
if got != tc.want {
|
|
t.Errorf("isMonitorOnlyAlert() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietHoursCategoryForAlert tests the quietHoursCategoryForAlert function
|
|
func TestQuietHoursCategoryForAlert(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
alert *Alert
|
|
want string
|
|
}{
|
|
// Nil alert
|
|
{
|
|
name: "nil alert returns empty",
|
|
alert: nil,
|
|
want: "",
|
|
},
|
|
|
|
// Performance metrics
|
|
{
|
|
name: "cpu type returns performance",
|
|
alert: &Alert{Type: "cpu"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "memory type returns performance",
|
|
alert: &Alert{Type: "memory"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "disk type returns performance",
|
|
alert: &Alert{Type: "disk"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "diskRead type returns performance",
|
|
alert: &Alert{Type: "diskRead"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "diskWrite type returns performance",
|
|
alert: &Alert{Type: "diskWrite"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "networkIn type returns performance",
|
|
alert: &Alert{Type: "networkIn"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "networkOut type returns performance",
|
|
alert: &Alert{Type: "networkOut"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "temperature type returns performance",
|
|
alert: &Alert{Type: "temperature"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "queue-depth returns performance",
|
|
alert: &Alert{Type: "queue-depth"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "queue-deferred returns performance",
|
|
alert: &Alert{Type: "queue-deferred"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "queue-hold returns performance",
|
|
alert: &Alert{Type: "queue-hold"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "message-age returns performance",
|
|
alert: &Alert{Type: "message-age"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-health returns performance",
|
|
alert: &Alert{Type: "docker-container-health"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-restart-loop returns performance",
|
|
alert: &Alert{Type: "docker-container-restart-loop"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-oom-kill returns performance",
|
|
alert: &Alert{Type: "docker-container-oom-kill"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-memory-limit returns performance",
|
|
alert: &Alert{Type: "docker-container-memory-limit"},
|
|
want: "performance",
|
|
},
|
|
|
|
// Storage metrics
|
|
{
|
|
name: "usage type returns storage",
|
|
alert: &Alert{Type: "usage"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "disk-health returns storage",
|
|
alert: &Alert{Type: "disk-health"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "disk-wearout returns storage",
|
|
alert: &Alert{Type: "disk-wearout"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "zfs-pool-state returns storage",
|
|
alert: &Alert{Type: "zfs-pool-state"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "zfs-pool-errors returns storage",
|
|
alert: &Alert{Type: "zfs-pool-errors"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "zfs-device returns storage",
|
|
alert: &Alert{Type: "zfs-device"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "storage-incident returns storage",
|
|
alert: &Alert{Type: "storage-incident"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "backup-storage-incident returns storage",
|
|
alert: &Alert{Type: "backup-storage-incident"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "backup-posture-incident returns storage",
|
|
alert: &Alert{Type: "backup-posture-incident"},
|
|
want: "storage",
|
|
},
|
|
{
|
|
name: "resource-incident availability returns offline",
|
|
alert: &Alert{
|
|
Type: "resource-incident",
|
|
Metadata: map[string]interface{}{
|
|
"incidentCategory": "availability",
|
|
},
|
|
},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "resource-incident health returns performance",
|
|
alert: &Alert{
|
|
Type: "resource-incident",
|
|
Metadata: map[string]interface{}{
|
|
"incidentCategory": "health",
|
|
},
|
|
},
|
|
want: "performance",
|
|
},
|
|
|
|
// Offline metrics
|
|
{
|
|
name: "connectivity type returns offline",
|
|
alert: &Alert{Type: "connectivity"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "offline type returns offline",
|
|
alert: &Alert{Type: "offline"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "powered-off type returns offline",
|
|
alert: &Alert{Type: "powered-off"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "docker-host-offline returns offline",
|
|
alert: &Alert{Type: "docker-host-offline"},
|
|
want: "offline",
|
|
},
|
|
|
|
// Docker container prefix handling
|
|
{
|
|
name: "docker-container-state returns offline",
|
|
alert: &Alert{Type: "docker-container-state"},
|
|
want: "offline",
|
|
},
|
|
{
|
|
name: "docker-container-cpu returns performance (prefix match)",
|
|
alert: &Alert{Type: "docker-container-cpu"},
|
|
want: "performance",
|
|
},
|
|
{
|
|
name: "docker-container-disk returns performance (prefix match)",
|
|
alert: &Alert{Type: "docker-container-disk"},
|
|
want: "performance",
|
|
},
|
|
|
|
// Unknown types
|
|
{
|
|
name: "unknown type returns empty",
|
|
alert: &Alert{Type: "unknown-type"},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty type returns empty",
|
|
alert: &Alert{Type: ""},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := quietHoursCategoryForAlert(tc.alert)
|
|
if got != tc.want {
|
|
t.Errorf("quietHoursCategoryForAlert(%v) = %q, want %q", tc.alert, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCanonicalResourceTypeKeys tests the CanonicalResourceTypeKeys function
|
|
func TestCanonicalResourceTypeKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resourceType string
|
|
want []string
|
|
}{
|
|
{name: "guest category preserved", resourceType: "guest", want: []string{"guest"}},
|
|
{name: "vm canonical + guest fallback", resourceType: "vm", want: []string{"vm", "guest"}},
|
|
{
|
|
name: "system-container canonical + guest fallback",
|
|
resourceType: "system-container",
|
|
want: []string{"system-container", "guest"},
|
|
},
|
|
{
|
|
name: "oci-container canonical + system-container + guest fallback",
|
|
resourceType: "oci-container",
|
|
want: []string{"oci-container", "system-container", "guest"},
|
|
},
|
|
{
|
|
name: "app-container canonical + guest fallback",
|
|
resourceType: "app-container",
|
|
want: []string{"app-container", "guest"},
|
|
},
|
|
{
|
|
name: "docker-host canonical + node fallback",
|
|
resourceType: "docker-host",
|
|
want: []string{"docker-host", "node"},
|
|
},
|
|
{
|
|
name: "docker-service canonical + app-container + guest fallback",
|
|
resourceType: "docker-service",
|
|
want: []string{"docker-service", "app-container", "guest"},
|
|
},
|
|
{name: "node canonical", resourceType: "node", want: []string{"node"}},
|
|
{name: "agent canonical + node fallback", resourceType: "agent", want: []string{"agent", "node"}},
|
|
{
|
|
name: "agent-disk canonical + storage fallbacks",
|
|
resourceType: "agent-disk",
|
|
want: []string{"agent-disk", "agent", "storage"},
|
|
},
|
|
{name: "pbs canonical + node fallback", resourceType: "pbs", want: []string{"pbs", "node"}},
|
|
{name: "pmg canonical + node fallback", resourceType: "pmg", want: []string{"pmg", "node"}},
|
|
{
|
|
name: "k8s cluster canonical + node fallback",
|
|
resourceType: "k8s-cluster",
|
|
want: []string{"k8s-cluster", "node"},
|
|
},
|
|
{
|
|
name: "k8s node canonical + node fallback",
|
|
resourceType: "k8s-node",
|
|
want: []string{"k8s-node", "node"},
|
|
},
|
|
{
|
|
name: "k8s deployment canonical + guest fallback",
|
|
resourceType: "k8s-deployment",
|
|
want: []string{"k8s-deployment", "guest"},
|
|
},
|
|
{
|
|
name: "k8s namespace canonical",
|
|
resourceType: "k8s-namespace",
|
|
want: []string{"k8s-namespace"},
|
|
},
|
|
{
|
|
name: "kubernetes display alias maps to pod",
|
|
resourceType: "Kubernetes Pod",
|
|
want: []string{"pod", "guest"},
|
|
},
|
|
{name: "pod canonical + guest fallback", resourceType: "pod", want: []string{"pod", "guest"}},
|
|
{
|
|
name: "truenas system canonical + agent fallback",
|
|
resourceType: "truenas-system",
|
|
want: []string{"truenas-system", "agent", "node"},
|
|
},
|
|
{
|
|
name: "truenas pool canonical + storage fallback",
|
|
resourceType: "truenas-pool",
|
|
want: []string{"truenas-pool", "storage"},
|
|
},
|
|
{
|
|
name: "truenas dataset display alias",
|
|
resourceType: "TrueNAS Dataset",
|
|
want: []string{"truenas-dataset", "storage"},
|
|
},
|
|
{
|
|
name: "truenas disk canonical + disk/storage fallback",
|
|
resourceType: "truenas-disk",
|
|
want: []string{"truenas-disk", "physical_disk", "disk", "storage"},
|
|
},
|
|
{
|
|
name: "vSphere host canonical + agent fallback",
|
|
resourceType: "vSphere Host",
|
|
want: []string{"vmware-host", "agent", "node"},
|
|
},
|
|
{
|
|
name: "VMware VM canonical + guest fallback",
|
|
resourceType: "VMware VM",
|
|
want: []string{"vmware-vm", "vm", "guest"},
|
|
},
|
|
{
|
|
name: "vSphere datastore canonical + storage fallback",
|
|
resourceType: "vSphere Datastore",
|
|
want: []string{"vmware-datastore", "storage"},
|
|
},
|
|
{
|
|
name: "VMware network canonical + network fallback",
|
|
resourceType: "VMware Network",
|
|
want: []string{"vmware-network", "network"},
|
|
},
|
|
{name: "storage canonical", resourceType: "storage", want: []string{"storage"}},
|
|
{name: "disk canonical + storage fallback", resourceType: "disk", want: []string{"disk", "storage"}},
|
|
{
|
|
name: "datastore canonical + storage + pbs fallback",
|
|
resourceType: "datastore",
|
|
want: []string{"datastore", "storage", "pbs"},
|
|
},
|
|
{name: "pool canonical + storage fallback", resourceType: "pool", want: []string{"pool", "storage"}},
|
|
{
|
|
name: "dataset canonical + storage fallback",
|
|
resourceType: "dataset",
|
|
want: []string{"dataset", "storage"},
|
|
},
|
|
{name: "ceph canonical + storage fallback", resourceType: "ceph", want: []string{"ceph", "storage"}},
|
|
{
|
|
name: "physical_disk canonical + storage fallback",
|
|
resourceType: "physical_disk",
|
|
want: []string{"physical_disk", "disk", "storage"},
|
|
},
|
|
{name: "unknown type passthrough", resourceType: "custom", want: []string{"custom"}},
|
|
{name: "legacy host alias rejected", resourceType: "host", want: []string{}},
|
|
{name: "legacy container alias rejected", resourceType: "container", want: []string{}},
|
|
{name: "legacy docker alias rejected", resourceType: "docker", want: []string{}},
|
|
{name: "legacy dockerhost alias rejected", resourceType: "dockerhost", want: []string{}},
|
|
{name: "legacy k8s alias rejected", resourceType: "k8s", want: []string{}},
|
|
{name: "empty string returns empty slice", resourceType: "", want: []string{}},
|
|
{name: "whitespace only returns empty slice", resourceType: " ", want: []string{}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := CanonicalResourceTypeKeys(tc.resourceType)
|
|
|
|
// Check length
|
|
if len(got) != len(tc.want) {
|
|
t.Errorf("CanonicalResourceTypeKeys(%q) = %v (len %d), want %v (len %d)",
|
|
tc.resourceType, got, len(got), tc.want, len(tc.want))
|
|
return
|
|
}
|
|
|
|
// Check each element
|
|
for i, v := range got {
|
|
if v != tc.want[i] {
|
|
t.Errorf("CanonicalResourceTypeKeys(%q)[%d] = %q, want %q",
|
|
tc.resourceType, i, v, tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEnsureValidHysteresis tests the ensureValidHysteresis function
|
|
func TestEnsureValidHysteresis(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
threshold *HysteresisThreshold
|
|
metricName string
|
|
wantTrigger float64
|
|
wantClear float64
|
|
expectChange bool
|
|
}{
|
|
{
|
|
name: "nil threshold does nothing",
|
|
threshold: nil,
|
|
metricName: "cpu",
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "valid threshold unchanged",
|
|
threshold: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
metricName: "cpu",
|
|
wantTrigger: 90,
|
|
wantClear: 85,
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "clear < trigger is valid",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
metricName: "memory",
|
|
wantTrigger: 80,
|
|
wantClear: 70,
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "clear == trigger is auto-fixed",
|
|
threshold: &HysteresisThreshold{Trigger: 90, Clear: 90},
|
|
metricName: "disk",
|
|
wantTrigger: 90,
|
|
wantClear: 85, // 90 - 5 = 85
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "clear > trigger is auto-fixed",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 90},
|
|
metricName: "network",
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5 = 75
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "auto-fix clamps clear to 0 for low trigger",
|
|
threshold: &HysteresisThreshold{Trigger: 3, Clear: 5},
|
|
metricName: "low",
|
|
wantTrigger: 3,
|
|
wantClear: 0, // 3 - 5 = -2, clamped to 0
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "trigger at 5 with invalid clear",
|
|
threshold: &HysteresisThreshold{Trigger: 5, Clear: 10},
|
|
metricName: "edge",
|
|
wantTrigger: 5,
|
|
wantClear: 0, // 5 - 5 = 0
|
|
expectChange: true,
|
|
},
|
|
{
|
|
name: "zero trigger with positive clear is skipped (disabled)",
|
|
threshold: &HysteresisThreshold{Trigger: 0, Clear: 5},
|
|
metricName: "zero",
|
|
wantTrigger: 0,
|
|
wantClear: 5, // Disabled thresholds are left as-is
|
|
expectChange: false, // No change — disabled threshold skipped
|
|
},
|
|
{
|
|
name: "both zero triggers skipped (disabled)",
|
|
threshold: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
|
metricName: "disabled",
|
|
wantTrigger: 0,
|
|
wantClear: 0,
|
|
expectChange: false, // No change — disabled threshold skipped
|
|
},
|
|
{
|
|
name: "large trigger with equal clear",
|
|
threshold: &HysteresisThreshold{Trigger: 100, Clear: 100},
|
|
metricName: "max",
|
|
wantTrigger: 100,
|
|
wantClear: 95, // 100 - 5 = 95
|
|
expectChange: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.threshold == nil {
|
|
// Just ensure it doesn't panic
|
|
ensureValidHysteresis(nil, tc.metricName)
|
|
return
|
|
}
|
|
|
|
// Make a copy to check if it changed
|
|
originalTrigger := tc.threshold.Trigger
|
|
originalClear := tc.threshold.Clear
|
|
|
|
ensureValidHysteresis(tc.threshold, tc.metricName)
|
|
|
|
if tc.threshold.Trigger != tc.wantTrigger {
|
|
t.Errorf("Trigger = %v, want %v", tc.threshold.Trigger, tc.wantTrigger)
|
|
}
|
|
if tc.threshold.Clear != tc.wantClear {
|
|
t.Errorf("Clear = %v, want %v", tc.threshold.Clear, tc.wantClear)
|
|
}
|
|
|
|
// Verify expectChange matches reality
|
|
changed := tc.threshold.Trigger != originalTrigger || tc.threshold.Clear != originalClear
|
|
if changed != tc.expectChange {
|
|
t.Errorf("expectChange = %v, but changed = %v", tc.expectChange, changed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCloneThreshold tests the cloneThreshold function
|
|
func TestCloneThreshold(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
threshold *HysteresisThreshold
|
|
}{
|
|
{
|
|
name: "nil threshold returns nil",
|
|
threshold: nil,
|
|
},
|
|
{
|
|
name: "basic threshold is cloned",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
{
|
|
name: "zero values threshold",
|
|
threshold: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
|
},
|
|
{
|
|
name: "large values threshold",
|
|
threshold: &HysteresisThreshold{Trigger: 100, Clear: 95},
|
|
},
|
|
{
|
|
name: "fractional values threshold",
|
|
threshold: &HysteresisThreshold{Trigger: 85.5, Clear: 80.25},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := cloneThreshold(tc.threshold)
|
|
|
|
if tc.threshold == nil {
|
|
if result != nil {
|
|
t.Errorf("cloneThreshold(nil) = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Result should not be nil
|
|
if result == nil {
|
|
t.Fatalf("cloneThreshold() returned nil for non-nil input")
|
|
}
|
|
|
|
// Result should be a different pointer
|
|
if result == tc.threshold {
|
|
t.Error("cloneThreshold() returned same pointer, not a clone")
|
|
}
|
|
|
|
// Values should match
|
|
if result.Trigger != tc.threshold.Trigger {
|
|
t.Errorf("cloneThreshold().Trigger = %v, want %v", result.Trigger, tc.threshold.Trigger)
|
|
}
|
|
if result.Clear != tc.threshold.Clear {
|
|
t.Errorf("cloneThreshold().Clear = %v, want %v", result.Clear, tc.threshold.Clear)
|
|
}
|
|
|
|
// Modifying clone should not affect original
|
|
result.Trigger = 999
|
|
result.Clear = 888
|
|
if tc.threshold.Trigger == 999 || tc.threshold.Clear == 888 {
|
|
t.Error("modifying clone affected original threshold")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCloneStringPtr tests the cloneStringPtr function
|
|
func TestCloneStringPtr(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
value *string
|
|
}{
|
|
{
|
|
name: "nil returns nil",
|
|
value: nil,
|
|
},
|
|
{
|
|
name: "empty string is cloned",
|
|
value: strPtr(""),
|
|
},
|
|
{
|
|
name: "basic string is cloned",
|
|
value: strPtr("hello"),
|
|
},
|
|
{
|
|
name: "string with spaces",
|
|
value: strPtr("hello world"),
|
|
},
|
|
{
|
|
name: "unicode string",
|
|
value: strPtr("こんにちは"),
|
|
},
|
|
{
|
|
name: "long string",
|
|
value: strPtr(strings.Repeat("a", 1000)),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := cloneStringPtr(tc.value)
|
|
|
|
if tc.value == nil {
|
|
if result != nil {
|
|
t.Errorf("cloneStringPtr(nil) = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Result should not be nil
|
|
if result == nil {
|
|
t.Fatalf("cloneStringPtr() returned nil for non-nil input")
|
|
}
|
|
|
|
// Result should be a different pointer
|
|
if result == tc.value {
|
|
t.Error("cloneStringPtr() returned same pointer, not a clone")
|
|
}
|
|
|
|
// Values should match
|
|
if *result != *tc.value {
|
|
t.Errorf("cloneStringPtr() value = %q, want %q", *result, *tc.value)
|
|
}
|
|
|
|
// Modifying clone should not affect original
|
|
originalValue := *tc.value
|
|
*result = "modified"
|
|
if *tc.value != originalValue {
|
|
t.Error("modifying clone affected original string")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// strPtr is a helper to create a string pointer
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
// TestCloneThresholdConfig tests the cloneThresholdConfig function
|
|
func TestCloneThresholdConfig(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config ThresholdConfig
|
|
}{
|
|
{
|
|
name: "empty config",
|
|
config: ThresholdConfig{},
|
|
},
|
|
{
|
|
name: "config with CPU threshold",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
},
|
|
{
|
|
name: "config with all thresholds",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
Memory: &HysteresisThreshold{Trigger: 85, Clear: 75},
|
|
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
DiskRead: &HysteresisThreshold{Trigger: 50, Clear: 40},
|
|
DiskWrite: &HysteresisThreshold{Trigger: 50, Clear: 40},
|
|
NetworkIn: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
NetworkOut: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
Temperature: &HysteresisThreshold{Trigger: 70, Clear: 60},
|
|
Usage: &HysteresisThreshold{Trigger: 85, Clear: 75},
|
|
},
|
|
},
|
|
{
|
|
name: "config with note",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
Note: strPtr("Test note for this config"),
|
|
},
|
|
},
|
|
{
|
|
name: "config with disabled flag",
|
|
config: ThresholdConfig{
|
|
Disabled: true,
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
},
|
|
{
|
|
name: "config with disable connectivity flag",
|
|
config: ThresholdConfig{
|
|
DisableConnectivity: true,
|
|
Memory: &HysteresisThreshold{Trigger: 90, Clear: 80},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := cloneThresholdConfig(tc.config)
|
|
|
|
// Check disabled flags
|
|
if result.Disabled != tc.config.Disabled {
|
|
t.Errorf("Disabled = %v, want %v", result.Disabled, tc.config.Disabled)
|
|
}
|
|
if result.DisableConnectivity != tc.config.DisableConnectivity {
|
|
t.Errorf("DisableConnectivity = %v, want %v", result.DisableConnectivity, tc.config.DisableConnectivity)
|
|
}
|
|
|
|
// Check that threshold pointers are different but values match
|
|
checkThresholdClone(t, "CPU", result.CPU, tc.config.CPU)
|
|
checkThresholdClone(t, "Memory", result.Memory, tc.config.Memory)
|
|
checkThresholdClone(t, "Disk", result.Disk, tc.config.Disk)
|
|
checkThresholdClone(t, "DiskRead", result.DiskRead, tc.config.DiskRead)
|
|
checkThresholdClone(t, "DiskWrite", result.DiskWrite, tc.config.DiskWrite)
|
|
checkThresholdClone(t, "NetworkIn", result.NetworkIn, tc.config.NetworkIn)
|
|
checkThresholdClone(t, "NetworkOut", result.NetworkOut, tc.config.NetworkOut)
|
|
checkThresholdClone(t, "Temperature", result.Temperature, tc.config.Temperature)
|
|
checkThresholdClone(t, "Usage", result.Usage, tc.config.Usage)
|
|
|
|
// Check Note cloning
|
|
if tc.config.Note == nil {
|
|
if result.Note != nil {
|
|
t.Errorf("Note should be nil")
|
|
}
|
|
} else {
|
|
if result.Note == nil {
|
|
t.Errorf("Note should not be nil")
|
|
} else if result.Note == tc.config.Note {
|
|
t.Error("Note pointer should be different")
|
|
} else if *result.Note != *tc.config.Note {
|
|
t.Errorf("Note value = %q, want %q", *result.Note, *tc.config.Note)
|
|
}
|
|
}
|
|
|
|
// Verify modifying clone doesn't affect original
|
|
if result.CPU != nil {
|
|
result.CPU.Trigger = 999
|
|
if tc.config.CPU != nil && tc.config.CPU.Trigger == 999 {
|
|
t.Error("modifying cloned CPU affected original")
|
|
}
|
|
}
|
|
if result.Note != nil {
|
|
*result.Note = "modified"
|
|
if tc.config.Note != nil && *tc.config.Note == "modified" {
|
|
t.Error("modifying cloned Note affected original")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkThresholdClone is a helper to verify a threshold was properly cloned
|
|
func checkThresholdClone(t *testing.T, name string, result, original *HysteresisThreshold) {
|
|
t.Helper()
|
|
if original == nil {
|
|
if result != nil {
|
|
t.Errorf("%s should be nil", name)
|
|
}
|
|
return
|
|
}
|
|
if result == nil {
|
|
t.Errorf("%s should not be nil", name)
|
|
return
|
|
}
|
|
if result == original {
|
|
t.Errorf("%s pointer should be different", name)
|
|
}
|
|
if result.Trigger != original.Trigger {
|
|
t.Errorf("%s.Trigger = %v, want %v", name, result.Trigger, original.Trigger)
|
|
}
|
|
if result.Clear != original.Clear {
|
|
t.Errorf("%s.Clear = %v, want %v", name, result.Clear, original.Clear)
|
|
}
|
|
}
|
|
|
|
// TestEnsureHysteresisThreshold tests the ensureHysteresisThreshold function
|
|
func TestEnsureHysteresisThreshold(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
threshold *HysteresisThreshold
|
|
wantTrigger float64
|
|
wantClear float64
|
|
}{
|
|
{
|
|
name: "nil threshold returns nil",
|
|
threshold: nil,
|
|
},
|
|
{
|
|
name: "threshold with valid clear unchanged",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
wantTrigger: 80,
|
|
wantClear: 70,
|
|
},
|
|
{
|
|
name: "threshold with zero clear gets default",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: 0},
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5
|
|
},
|
|
{
|
|
name: "threshold with negative clear gets default",
|
|
threshold: &HysteresisThreshold{Trigger: 80, Clear: -10},
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5
|
|
},
|
|
{
|
|
name: "low trigger value",
|
|
threshold: &HysteresisThreshold{Trigger: 10, Clear: 0},
|
|
wantTrigger: 10,
|
|
wantClear: 5, // 10 - 5
|
|
},
|
|
{
|
|
name: "trigger at 5 with zero clear",
|
|
threshold: &HysteresisThreshold{Trigger: 5, Clear: 0},
|
|
wantTrigger: 5,
|
|
wantClear: 0, // 5 - 5 = 0
|
|
},
|
|
{
|
|
name: "trigger below 5 with zero clear",
|
|
threshold: &HysteresisThreshold{Trigger: 3, Clear: 0},
|
|
wantTrigger: 3,
|
|
wantClear: -2, // 3 - 5 = -2 (function doesn't clamp)
|
|
},
|
|
{
|
|
name: "trigger at 100 with zero clear",
|
|
threshold: &HysteresisThreshold{Trigger: 100, Clear: 0},
|
|
wantTrigger: 100,
|
|
wantClear: 95, // 100 - 5
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := ensureHysteresisThreshold(tc.threshold)
|
|
|
|
if tc.threshold == nil {
|
|
if result != nil {
|
|
t.Errorf("ensureHysteresisThreshold(nil) = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatal("ensureHysteresisThreshold returned nil for non-nil input")
|
|
}
|
|
|
|
// Note: function modifies in place, so result == tc.threshold
|
|
if result.Trigger != tc.wantTrigger {
|
|
t.Errorf("Trigger = %v, want %v", result.Trigger, tc.wantTrigger)
|
|
}
|
|
if result.Clear != tc.wantClear {
|
|
t.Errorf("Clear = %v, want %v", result.Clear, tc.wantClear)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParsePulseTags tests the parsePulseTags function
|
|
func TestParsePulseTags(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
tags []string
|
|
want pulseTagSettings
|
|
}{
|
|
{
|
|
name: "nil tags",
|
|
tags: nil,
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "empty tags",
|
|
tags: []string{},
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "no pulse tags",
|
|
tags: []string{"production", "web-server", "ubuntu"},
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "pulse-no-alerts tag",
|
|
tags: []string{"pulse-no-alerts"},
|
|
want: pulseTagSettings{Suppress: true},
|
|
},
|
|
{
|
|
name: "pulse-monitor-only tag",
|
|
tags: []string{"pulse-monitor-only"},
|
|
want: pulseTagSettings{MonitorOnly: true},
|
|
},
|
|
{
|
|
name: "pulse-relaxed tag",
|
|
tags: []string{"pulse-relaxed"},
|
|
want: pulseTagSettings{Relaxed: true},
|
|
},
|
|
{
|
|
name: "all pulse tags",
|
|
tags: []string{"pulse-no-alerts", "pulse-monitor-only", "pulse-relaxed"},
|
|
want: pulseTagSettings{Suppress: true, MonitorOnly: true, Relaxed: true},
|
|
},
|
|
{
|
|
name: "mixed tags with pulse tags",
|
|
tags: []string{"production", "pulse-no-alerts", "web-server", "pulse-relaxed"},
|
|
want: pulseTagSettings{Suppress: true, Relaxed: true},
|
|
},
|
|
{
|
|
name: "uppercase pulse tag",
|
|
tags: []string{"PULSE-NO-ALERTS"},
|
|
want: pulseTagSettings{Suppress: true},
|
|
},
|
|
{
|
|
name: "mixed case pulse tag",
|
|
tags: []string{"Pulse-Monitor-Only"},
|
|
want: pulseTagSettings{MonitorOnly: true},
|
|
},
|
|
{
|
|
name: "pulse tag with whitespace",
|
|
tags: []string{" pulse-no-alerts "},
|
|
want: pulseTagSettings{Suppress: true},
|
|
},
|
|
{
|
|
name: "pulse tag with leading whitespace",
|
|
tags: []string{" pulse-relaxed"},
|
|
want: pulseTagSettings{Relaxed: true},
|
|
},
|
|
{
|
|
name: "pulse tag with trailing whitespace",
|
|
tags: []string{"pulse-monitor-only "},
|
|
want: pulseTagSettings{MonitorOnly: true},
|
|
},
|
|
{
|
|
name: "similar but not pulse tag",
|
|
tags: []string{"pulse-alerts", "pulse-monitor", "pulse"},
|
|
want: pulseTagSettings{},
|
|
},
|
|
{
|
|
name: "pulse tag substring does not match",
|
|
tags: []string{"mypulse-no-alerts", "pulse-no-alerts-extra"},
|
|
want: pulseTagSettings{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := parsePulseTags(tc.tags)
|
|
|
|
if result.Suppress != tc.want.Suppress {
|
|
t.Errorf("Suppress = %v, want %v", result.Suppress, tc.want.Suppress)
|
|
}
|
|
if result.MonitorOnly != tc.want.MonitorOnly {
|
|
t.Errorf("MonitorOnly = %v, want %v", result.MonitorOnly, tc.want.MonitorOnly)
|
|
}
|
|
if result.Relaxed != tc.want.Relaxed {
|
|
t.Errorf("Relaxed = %v, want %v", result.Relaxed, tc.want.Relaxed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNormalizeMetricTimeThresholds tests the normalizeMetricTimeThresholds function
|
|
func TestNormalizeMetricTimeThresholds(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input map[string]map[string]int
|
|
want map[string]map[string]int
|
|
}{
|
|
{
|
|
name: "nil input returns nil",
|
|
input: nil,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "empty input returns nil",
|
|
input: map[string]map[string]int{},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "valid input is normalized",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "keys are lowercased",
|
|
input: map[string]map[string]int{
|
|
"GUEST": {"CPU": 60, "MEMORY": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "keys are trimmed",
|
|
input: map[string]map[string]int{
|
|
" guest ": {" cpu ": 60},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60},
|
|
},
|
|
},
|
|
{
|
|
name: "negative delays are dropped",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": -1},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60},
|
|
},
|
|
},
|
|
{
|
|
name: "zero delay is valid",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 0},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 0},
|
|
},
|
|
},
|
|
{
|
|
name: "empty type key is dropped",
|
|
input: map[string]map[string]int{
|
|
"": {"cpu": 60},
|
|
"guest": {"memory": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "whitespace-only type key is dropped",
|
|
input: map[string]map[string]int{
|
|
" ": {"cpu": 60},
|
|
"guest": {"memory": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"memory": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "empty metric key is dropped",
|
|
input: map[string]map[string]int{
|
|
"guest": {"": 60, "cpu": 120},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "empty metrics map is dropped",
|
|
input: map[string]map[string]int{
|
|
"guest": {},
|
|
"node": {"cpu": 60},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"node": {"cpu": 60},
|
|
},
|
|
},
|
|
{
|
|
name: "all invalid results in nil",
|
|
input: map[string]map[string]int{
|
|
"": {"cpu": 60},
|
|
"guest": {"": 60, "memory": -1},
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "legacy host alias type key is dropped",
|
|
input: map[string]map[string]int{
|
|
"host": {"cpu": 60},
|
|
"vm": {"cpu": 30},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"vm": {"cpu": 30},
|
|
},
|
|
},
|
|
{
|
|
name: "legacy docker alias type key is dropped",
|
|
input: map[string]map[string]int{
|
|
"docker": {"cpu": 60},
|
|
"app-container": {"cpu": 30},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"app-container": {"cpu": 30},
|
|
},
|
|
},
|
|
{
|
|
name: "all bucket is preserved",
|
|
input: map[string]map[string]int{
|
|
"all": {"cpu": 120},
|
|
"host": {"cpu": 60},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"all": {"cpu": 120},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple resource types",
|
|
input: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
"node": {"disk": 30},
|
|
},
|
|
want: map[string]map[string]int{
|
|
"guest": {"cpu": 60, "memory": 120},
|
|
"node": {"disk": 30},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := NormalizeMetricTimeThresholds(tc.input)
|
|
|
|
if tc.want == nil {
|
|
if result != nil {
|
|
t.Errorf("normalizeMetricTimeThresholds() = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatalf("normalizeMetricTimeThresholds() = nil, want %v", tc.want)
|
|
}
|
|
|
|
// Check all expected keys exist with correct values
|
|
for typeKey, metrics := range tc.want {
|
|
resultMetrics, exists := result[typeKey]
|
|
if !exists {
|
|
t.Errorf("missing type key %q", typeKey)
|
|
continue
|
|
}
|
|
for metricKey, delay := range metrics {
|
|
resultDelay, exists := resultMetrics[metricKey]
|
|
if !exists {
|
|
t.Errorf("missing metric key %q for type %q", metricKey, typeKey)
|
|
continue
|
|
}
|
|
if resultDelay != delay {
|
|
t.Errorf("result[%q][%q] = %d, want %d", typeKey, metricKey, resultDelay, delay)
|
|
}
|
|
}
|
|
// Check no extra metric keys
|
|
if len(resultMetrics) != len(metrics) {
|
|
t.Errorf("result[%q] has %d keys, want %d", typeKey, len(resultMetrics), len(metrics))
|
|
}
|
|
}
|
|
// Check no extra type keys
|
|
if len(result) != len(tc.want) {
|
|
t.Errorf("result has %d type keys, want %d", len(result), len(tc.want))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetThresholdForMetric tests the getThresholdForMetric function
|
|
func TestGetThresholdForMetric(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
cpuThreshold := &HysteresisThreshold{Trigger: 80, Clear: 70}
|
|
memoryThreshold := &HysteresisThreshold{Trigger: 85, Clear: 75}
|
|
diskThreshold := &HysteresisThreshold{Trigger: 90, Clear: 85}
|
|
diskReadThreshold := &HysteresisThreshold{Trigger: 50, Clear: 40}
|
|
diskWriteThreshold := &HysteresisThreshold{Trigger: 55, Clear: 45}
|
|
networkInThreshold := &HysteresisThreshold{Trigger: 70, Clear: 60}
|
|
networkOutThreshold := &HysteresisThreshold{Trigger: 75, Clear: 65}
|
|
temperatureThreshold := &HysteresisThreshold{Trigger: 65, Clear: 55}
|
|
usageThreshold := &HysteresisThreshold{Trigger: 88, Clear: 78}
|
|
|
|
config := ThresholdConfig{
|
|
CPU: cpuThreshold,
|
|
Memory: memoryThreshold,
|
|
Disk: diskThreshold,
|
|
DiskRead: diskReadThreshold,
|
|
DiskWrite: diskWriteThreshold,
|
|
NetworkIn: networkInThreshold,
|
|
NetworkOut: networkOutThreshold,
|
|
Temperature: temperatureThreshold,
|
|
Usage: usageThreshold,
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
metricType string
|
|
want *HysteresisThreshold
|
|
}{
|
|
{"cpu", "cpu", cpuThreshold},
|
|
{"memory", "memory", memoryThreshold},
|
|
{"disk", "disk", diskThreshold},
|
|
{"diskRead", "diskRead", diskReadThreshold},
|
|
{"diskWrite", "diskWrite", diskWriteThreshold},
|
|
{"networkIn", "networkIn", networkInThreshold},
|
|
{"networkOut", "networkOut", networkOutThreshold},
|
|
{"temperature", "temperature", temperatureThreshold},
|
|
{"usage", "usage", usageThreshold},
|
|
{"unknown metric", "unknown", nil},
|
|
{"empty metric", "", nil},
|
|
{"case sensitive - CPU", "CPU", nil},
|
|
{"case sensitive - Memory", "Memory", nil},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := getThresholdForMetric(config, tc.metricType)
|
|
if result != tc.want {
|
|
t.Errorf("getThresholdForMetric(%q) = %v, want %v", tc.metricType, result, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetThresholdForMetric_EmptyConfig tests getThresholdForMetric with empty config
|
|
func TestGetThresholdForMetric_EmptyConfig(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
config := ThresholdConfig{}
|
|
|
|
metricTypes := []string{"cpu", "memory", "disk", "diskRead", "diskWrite", "networkIn", "networkOut", "temperature", "usage"}
|
|
|
|
for _, metricType := range metricTypes {
|
|
t.Run(metricType, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := getThresholdForMetric(config, metricType)
|
|
if result != nil {
|
|
t.Errorf("getThresholdForMetric(%q) with empty config = %v, want nil", metricType, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetThresholdForMetricFromConfig tests the getThresholdForMetricFromConfig function
|
|
func TestGetThresholdForMetricFromConfig(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config ThresholdConfig
|
|
metricType string
|
|
wantNil bool
|
|
wantTrigger float64
|
|
wantClear float64
|
|
}{
|
|
{
|
|
name: "empty config returns nil",
|
|
config: ThresholdConfig{},
|
|
metricType: "cpu",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "cpu threshold returned",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
metricType: "cpu",
|
|
wantTrigger: 80,
|
|
wantClear: 70,
|
|
},
|
|
{
|
|
name: "memory threshold returned",
|
|
config: ThresholdConfig{
|
|
Memory: &HysteresisThreshold{Trigger: 85, Clear: 75},
|
|
},
|
|
metricType: "memory",
|
|
wantTrigger: 85,
|
|
wantClear: 75,
|
|
},
|
|
{
|
|
name: "disk threshold returned",
|
|
config: ThresholdConfig{
|
|
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
},
|
|
metricType: "disk",
|
|
wantTrigger: 90,
|
|
wantClear: 85,
|
|
},
|
|
{
|
|
name: "diskRead threshold returned",
|
|
config: ThresholdConfig{
|
|
DiskRead: &HysteresisThreshold{Trigger: 50, Clear: 40},
|
|
},
|
|
metricType: "diskRead",
|
|
wantTrigger: 50,
|
|
wantClear: 40,
|
|
},
|
|
{
|
|
name: "diskWrite threshold returned",
|
|
config: ThresholdConfig{
|
|
DiskWrite: &HysteresisThreshold{Trigger: 55, Clear: 45},
|
|
},
|
|
metricType: "diskWrite",
|
|
wantTrigger: 55,
|
|
wantClear: 45,
|
|
},
|
|
{
|
|
name: "networkIn threshold returned",
|
|
config: ThresholdConfig{
|
|
NetworkIn: &HysteresisThreshold{Trigger: 70, Clear: 60},
|
|
},
|
|
metricType: "networkIn",
|
|
wantTrigger: 70,
|
|
wantClear: 60,
|
|
},
|
|
{
|
|
name: "networkOut threshold returned",
|
|
config: ThresholdConfig{
|
|
NetworkOut: &HysteresisThreshold{Trigger: 75, Clear: 65},
|
|
},
|
|
metricType: "networkOut",
|
|
wantTrigger: 75,
|
|
wantClear: 65,
|
|
},
|
|
{
|
|
name: "temperature threshold returned",
|
|
config: ThresholdConfig{
|
|
Temperature: &HysteresisThreshold{Trigger: 65, Clear: 55},
|
|
},
|
|
metricType: "temperature",
|
|
wantTrigger: 65,
|
|
wantClear: 55,
|
|
},
|
|
{
|
|
name: "usage threshold returned",
|
|
config: ThresholdConfig{
|
|
Usage: &HysteresisThreshold{Trigger: 88, Clear: 78},
|
|
},
|
|
metricType: "usage",
|
|
wantTrigger: 88,
|
|
wantClear: 78,
|
|
},
|
|
{
|
|
name: "unknown metric returns nil",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 70},
|
|
},
|
|
metricType: "unknown",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "threshold with zero clear gets default hysteresis",
|
|
config: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 0},
|
|
},
|
|
metricType: "cpu",
|
|
wantTrigger: 80,
|
|
wantClear: 75, // 80 - 5 default margin
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// t.Parallel()
|
|
|
|
result := getThresholdForMetricFromConfig(tc.config, tc.metricType)
|
|
|
|
if tc.wantNil {
|
|
if result != nil {
|
|
t.Errorf("getThresholdForMetricFromConfig() = %v, want nil", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatalf("getThresholdForMetricFromConfig() = nil, want non-nil")
|
|
}
|
|
|
|
if result.Trigger != tc.wantTrigger {
|
|
t.Errorf("Trigger = %v, want %v", result.Trigger, tc.wantTrigger)
|
|
}
|
|
if result.Clear != tc.wantClear {
|
|
t.Errorf("Clear = %v, want %v", result.Clear, tc.wantClear)
|
|
}
|
|
})
|
|
}
|
|
}
|