Files
karma/internal/models/alert_test.go
2026-03-11 13:10:15 +00:00

449 lines
12 KiB
Go

package models_test
import (
"encoding/json"
"fmt"
"slices"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/prometheus/prometheus/model/labels"
"github.com/prymitive/karma/internal/config"
"github.com/prymitive/karma/internal/models"
)
func TestLabelsSetIfMissing(t *testing.T) {
// verifies that LabelsSetIfMissing adds a label when missing
l := labels.EmptyLabels()
l = models.LabelsSetIfMissing(l, "foo", "bar")
if l.Get("foo") != "bar" {
t.Errorf("Expected foo=bar, got foo=%s", l.Get("foo"))
}
// verifies that LabelsSetIfMissing does not overwrite an existing label
l = models.LabelsSetIfMissing(l, "foo", "baz")
if l.Get("foo") != "bar" {
t.Errorf("Expected foo=bar (unchanged), got foo=%s", l.Get("foo"))
}
// verifies that LabelsSetIfMissing adds a second label
l = models.LabelsSetIfMissing(l, "bar", "foo")
if l.Get("bar") != "foo" {
t.Errorf("Expected bar=foo, got bar=%s", l.Get("bar"))
}
if l.Get("foo") != "bar" {
t.Errorf("Expected foo=bar (still), got foo=%s", l.Get("foo"))
}
}
type sortOrderedLabelsTestCase struct {
order []string
in models.OrderedLabels
out models.OrderedLabels
}
func TestSortOrderedLabels(t *testing.T) {
testCases := []sortOrderedLabelsTestCase{
// verifies that a single label stays in place
{
order: []string{},
in: models.OrderedLabels{{Name: "foo", Value: "bar"}},
out: models.OrderedLabels{{Name: "foo", Value: "bar"}},
},
// verifies that two labels are sorted alphabetically by name
{
order: []string{},
in: models.OrderedLabels{
{Name: "foo", Value: "bar"},
{Name: "bar", Value: "foo"},
},
out: models.OrderedLabels{
{Name: "bar", Value: "foo"},
{Name: "foo", Value: "bar"},
},
},
// verifies that already-sorted labels remain stable
{
order: []string{},
in: models.OrderedLabels{
{Name: "bar", Value: "foo"},
{Name: "foo", Value: "bar"},
},
out: models.OrderedLabels{
{Name: "bar", Value: "foo"},
{Name: "foo", Value: "bar"},
},
},
// verifies that same-name labels sort by value naturally
{
order: []string{},
in: models.OrderedLabels{
{Name: "foo", Value: "foo"},
{Name: "bar", Value: "foo"},
{Name: "foo", Value: "bar"},
},
out: models.OrderedLabels{
{Name: "bar", Value: "foo"},
{Name: "foo", Value: "bar"},
{Name: "foo", Value: "foo"},
},
},
// verifies natural sort on values with numbers
{
order: []string{},
in: models.OrderedLabels{
{Name: "1", Value: "a12"},
{Name: "1", Value: "1"},
{Name: "1", Value: "a2"},
},
out: models.OrderedLabels{
{Name: "1", Value: "1"},
{Name: "1", Value: "a2"},
{Name: "1", Value: "a12"},
},
},
// verifies that configured order takes priority
{
order: []string{"bar"},
in: models.OrderedLabels{
{Name: "baz", Value: "1"},
{Name: "bar", Value: "1"},
{Name: "foo", Value: "1"},
},
out: models.OrderedLabels{
{Name: "bar", Value: "1"},
{Name: "baz", Value: "1"},
{Name: "foo", Value: "1"},
},
},
// verifies that multiple order entries sort correctly with natural value sort
{
order: []string{"foo", "bar"},
in: models.OrderedLabels{
{Name: "foo", Value: "a10"},
{Name: "bar", Value: "1"},
{Name: "foo", Value: "a3"},
},
out: models.OrderedLabels{
{Name: "foo", Value: "a3"},
{Name: "foo", Value: "a10"},
{Name: "bar", Value: "1"},
},
},
// verifies that two distinct labels both in config order sort by their position in the order list
{
order: []string{"bar", "foo"},
in: models.OrderedLabels{
{Name: "foo", Value: "1"},
{Name: "bar", Value: "1"},
},
out: models.OrderedLabels{
{Name: "bar", Value: "1"},
{Name: "foo", Value: "1"},
},
},
// verifies that identical labels stay in their original positions
{
order: []string{},
in: models.OrderedLabels{
{Name: "foo", Value: "bar"},
{Name: "foo", Value: "bar"},
},
out: models.OrderedLabels{
{Name: "foo", Value: "bar"},
{Name: "foo", Value: "bar"},
},
},
// verifies that same-name labels with different values sort by value naturally
{
order: []string{},
in: models.OrderedLabels{
{Name: "foo", Value: "z"},
{Name: "foo", Value: "a"},
},
out: models.OrderedLabels{
{Name: "foo", Value: "a"},
{Name: "foo", Value: "z"},
},
},
// verifies that labels with different names sort by name naturally
{
order: []string{},
in: models.OrderedLabels{
{Name: "zzz", Value: "1"},
{Name: "aaa", Value: "1"},
},
out: models.OrderedLabels{
{Name: "aaa", Value: "1"},
{Name: "zzz", Value: "1"},
},
},
}
defer func() {
config.Config.Labels.Order = []string{}
}()
for i, testCase := range testCases {
t.Run(fmt.Sprintf("[%d] order=%v", i, testCase.order), func(t *testing.T) {
config.Config.Labels.Order = testCase.order
slices.SortFunc(testCase.in, models.CompareOrderedLabels)
if diff := cmp.Diff(testCase.out, testCase.in); diff != "" {
t.Errorf("Incorrectly sorted labels (-want +got):\n%s", diff)
t.FailNow()
}
})
}
}
func TestCompareOrderedLabelsIdentical(t *testing.T) {
// verifies that comparing two identical labels returns 0
config.Config.Labels.Order = []string{}
a := models.OrderedLabel{Name: "foo", Value: "bar"}
b := models.OrderedLabel{Name: "foo", Value: "bar"}
got := models.CompareOrderedLabels(a, b)
if got != 0 {
t.Errorf("CompareOrderedLabels(%v, %v) = %d, want 0", a, b, got)
}
}
func TestLabelsMap(t *testing.T) {
type testCaseT struct {
labels labels.Labels
expected map[string]string
}
testCases := []testCaseT{
// verifies that empty labels produce an empty map
{
labels: labels.EmptyLabels(),
expected: map[string]string{},
},
// verifies that labels are converted to a name->value map
{
labels: labels.FromStrings("baz", "qux", "foo", "bar"),
expected: map[string]string{"foo": "bar", "baz": "qux"},
},
}
for _, tc := range testCases {
result := tc.labels.Map()
if diff := cmp.Diff(tc.expected, result); diff != "" {
t.Errorf("Labels.Map() mismatch (-want +got):\n%s", diff)
}
}
}
func TestLabelsGet(t *testing.T) {
ls := labels.FromStrings("baz", "qux", "foo", "bar")
type testCaseT struct {
name string
expected string
}
testCases := []testCaseT{
// verifies that an existing label returns its value
{name: "foo", expected: "bar"},
// verifies that another existing label returns its value
{name: "baz", expected: "qux"},
// verifies that a missing label returns an empty string
{name: "missing", expected: ""},
}
for _, tc := range testCases {
result := ls.Get(tc.name)
if result != tc.expected {
t.Errorf("Labels.Get(%q) returned %q, expected %q", tc.name, result, tc.expected)
}
}
}
func TestAlertStateJSONRoundTrip(t *testing.T) {
// verifies that AlertState survives a JSON marshal/unmarshal round-trip
original := models.AlertStateActive
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("json.Marshal failed: %s", err)
}
if string(data) != `"active"` {
t.Errorf("json.Marshal produced %s, expected %q", string(data), `"active"`)
}
var decoded models.AlertState
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("json.Unmarshal failed: %s", err)
}
if decoded != models.AlertStateActive {
t.Errorf("json.Unmarshal produced %v, expected %v", decoded, models.AlertStateActive)
}
}
func TestAlertStateUnmarshalJSONError(t *testing.T) {
// verifies that UnmarshalJSON returns an error for invalid JSON input
var s models.AlertState
err := json.Unmarshal([]byte(`{invalid`), &s)
if err == nil {
t.Error("json.Unmarshal should have returned an error for invalid JSON")
}
}
func TestAlertStateStringUnknown(t *testing.T) {
// verifies that an out-of-range AlertState falls back to "unprocessed"
unknown := models.AlertState(99)
if unknown.String() != "unprocessed" {
t.Errorf("AlertState(99).String() = %q, want %q", unknown.String(), "unprocessed")
}
}
func TestParseAlertStateUnknown(t *testing.T) {
// verifies that parsing an unknown string returns AlertStateUnprocessed
got := models.ParseAlertState("bogus")
if got != models.AlertStateUnprocessed {
t.Errorf("ParseAlertState(%q) = %v, want %v", "bogus", got, models.AlertStateUnprocessed)
}
}
func TestAlertStateMarshalText(t *testing.T) {
// verifies that MarshalText produces the expected string representation
tests := []struct {
state models.AlertState
expected string
}{
{state: models.AlertStateUnprocessed, expected: "unprocessed"},
{state: models.AlertStateActive, expected: "active"},
{state: models.AlertStateSuppressed, expected: "suppressed"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
data, err := tt.state.MarshalText()
if err != nil {
t.Fatalf("MarshalText() error: %v", err)
}
if string(data) != tt.expected {
t.Errorf("MarshalText() = %q, want %q", string(data), tt.expected)
}
})
}
}
func TestAlertStateUnmarshalText(t *testing.T) {
// verifies that UnmarshalText correctly parses known and unknown state strings
tests := []struct {
input string
expected models.AlertState
}{
{input: "active", expected: models.AlertStateActive},
{input: "suppressed", expected: models.AlertStateSuppressed},
{input: "unprocessed", expected: models.AlertStateUnprocessed},
{input: "unknown", expected: models.AlertStateUnprocessed},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
var s models.AlertState
err := s.UnmarshalText([]byte(tt.input))
if err != nil {
t.Fatalf("UnmarshalText(%q) error: %v", tt.input, err)
}
if s != tt.expected {
t.Errorf("UnmarshalText(%q) = %v, want %v", tt.input, s, tt.expected)
}
})
}
}
func TestUpdateFingerprints(t *testing.T) {
// verifies that UpdateFingerprints produces stable, non-empty fingerprints
// including the alertmanager instance, silenced-by, and inhibited-by branches
alert := models.Alert{
StartsAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
State: models.AlertStateActive,
Receiver: "default",
Fingerprint: "abc123",
Labels: labels.FromStrings("alertname", "TestAlert"),
Annotations: models.Annotations{
{
Name: "summary",
Value: "test summary",
Visible: true,
IsLink: false,
IsAction: false,
},
},
Alertmanager: []models.AlertmanagerInstance{
{
Fingerprint: "fp1",
Name: "am1",
Cluster: "cluster1",
State: models.AlertStateActive,
StartsAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
Source: "http://source",
SilencedBy: []string{"silence1", "silence2"},
InhibitedBy: []string{"inhibit1"},
},
},
}
alert.UpdateFingerprints()
if alert.LabelsFingerprint() == "" {
t.Error("LabelsFingerprint() returned empty string after UpdateFingerprints()")
}
if alert.ContentFingerprint() == "" {
t.Error("ContentFingerprint() returned empty string after UpdateFingerprints()")
}
// verifies that calling UpdateFingerprints again produces the same result
fp1 := alert.LabelsFingerprint()
cfp1 := alert.ContentFingerprint()
alert.UpdateFingerprints()
if alert.LabelsFingerprint() != fp1 {
t.Errorf("LabelsFingerprint() not stable: %q != %q", alert.LabelsFingerprint(), fp1)
}
if alert.ContentFingerprint() != cfp1 {
t.Errorf("ContentFingerprint() not stable: %q != %q", alert.ContentFingerprint(), cfp1)
}
// verifies that changing a label produces a different fingerprint
alert2 := alert
alert2.Labels = labels.FromStrings("alertname", "DifferentAlert")
alert2.UpdateFingerprints()
if alert2.LabelsFingerprint() == fp1 {
t.Error("LabelsFingerprint() should differ when labels change")
}
}
func TestLabelsToOrderedLabels(t *testing.T) {
// verifies that LabelsToOrderedLabels converts labels and applies display ordering
defer func() {
config.Config.Labels.Order = []string{}
}()
config.Config.Labels.Order = []string{"alertname"}
ls := labels.FromStrings("alertname", "TestAlert", "job", "node")
dl := models.LabelsToOrderedLabels(ls)
expected := models.OrderedLabels{
{Name: "alertname", Value: "TestAlert"},
{Name: "job", Value: "node"},
}
if diff := cmp.Diff(expected, dl); diff != "" {
t.Errorf("LabelsToOrderedLabels mismatch (-want +got):\n%s", diff)
}
}
func TestLabelsFromMap(t *testing.T) {
// verifies that LabelsFromMap creates labels from a map
m := map[string]string{"foo": "bar", "baz": "qux"}
ls := models.LabelsFromMap(m)
if ls.Get("foo") != "bar" {
t.Errorf("Expected foo=bar, got foo=%s", ls.Get("foo"))
}
if ls.Get("baz") != "qux" {
t.Errorf("Expected baz=qux, got baz=%s", ls.Get("baz"))
}
}