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

575 lines
16 KiB
Go

package models_test
import (
"encoding/json"
"reflect"
"testing"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/prymitive/karma/internal/models"
)
// TestMarshalJSONTo_MatchesV1 verifies that our custom MarshalJSONTo methods
// produce JSON that is semantically identical to encoding/json v1's
// reflection-based Marshal for the same struct values.
func TestMarshalJSONTo_MatchesV1(t *testing.T) {
ts := time.Date(2025, 3, 10, 12, 0, 0, 0, time.UTC)
tests := []struct {
// scenario describes the behaviour being tested
name string
val any
}{
{
// empty ordered labels slice should marshal to []
name: "OrderedLabels/empty",
val: models.OrderedLabels{},
},
{
// ordered labels with multiple entries
name: "OrderedLabels/populated",
val: models.OrderedLabels{
{Name: "alertname", Value: "TestAlert"},
{Name: "severity", Value: "critical"},
},
},
{
// single annotation with all boolean fields set
name: "Annotation/allFields",
val: models.Annotation{
Name: "summary",
Value: "Something broke",
Visible: true,
IsLink: false,
IsAction: true,
},
},
{
// empty annotations slice should marshal to []
name: "Annotations/empty",
val: models.Annotations{},
},
{
// annotations with mixed visibility and link flags
name: "Annotations/populated",
val: models.Annotations{
{Name: "summary", Value: "test", Visible: true, IsLink: false, IsAction: false},
{Name: "url", Value: "https://example.com", Visible: false, IsLink: true, IsAction: false},
},
},
{
// alert with all nested types populated
name: "APIAlert/full",
val: models.APIAlert{
StartsAt: ts,
State: "active",
Receiver: "default",
LabelsFP: "abc123",
Annotations: models.Annotations{
{Name: "summary", Value: "test alert", Visible: true},
},
Labels: models.OrderedLabels{
{Name: "alertname", Value: "TestAlert"},
},
Alertmanager: []models.AlertmanagerInstance{
{
StartsAt: ts,
Fingerprint: "fp1",
Name: "am1",
Cluster: "cluster1",
Source: "http://am1:9093",
SilencedBy: []string{"silence1"},
InhibitedBy: []string{},
State: models.AlertStateActive,
},
},
},
},
{
// alert with empty slices to verify they marshal as [] not null
name: "APIAlert/emptySlices",
val: models.APIAlert{
StartsAt: ts,
State: "suppressed",
Annotations: models.Annotations{},
Labels: models.OrderedLabels{},
Alertmanager: []models.AlertmanagerInstance{},
},
},
{
// alertmanager instance with all fields populated
name: "AlertmanagerInstance/full",
val: models.AlertmanagerInstance{
StartsAt: ts,
Fingerprint: "fp-abc",
Name: "prod-am",
Cluster: "us-east",
Source: "http://prod-am:9093/#/alerts",
SilencedBy: []string{"s1", "s2"},
InhibitedBy: []string{"i1"},
State: models.AlertStateSuppressed,
},
},
{
// shared maps with all nested types
name: "APIAlertGroupSharedMaps/full",
val: models.APIAlertGroupSharedMaps{
Annotations: models.Annotations{
{Name: "shared_ann", Value: "shared_val", Visible: true},
},
Labels: models.OrderedLabels{
{Name: "job", Value: "node_exporter"},
},
Silences: map[string][]string{
"am1": {"silence-a"},
},
Sources: []string{"http://am1:9093"},
Clusters: []string{"cluster1"},
},
},
{
// shared maps with empty collections
name: "APIAlertGroupSharedMaps/empty",
val: models.APIAlertGroupSharedMaps{
Annotations: models.Annotations{},
Labels: models.OrderedLabels{},
Silences: map[string][]string{},
Sources: []string{},
Clusters: []string{},
},
},
{
// filter with all fields populated
name: "Filter/full",
val: models.Filter{
Text: "@alertname=~test",
Name: "alertname",
Matcher: "=~",
Value: "test",
Hits: 42,
IsValid: true,
},
},
{
// label colors with background and brightness
name: "LabelColors/full",
val: models.LabelColors{
Background: "rgba(255,0,0,255)",
Brightness: 128,
},
},
{
// nested color map with multiple labels and values
name: "LabelsColorMap/populated",
val: models.LabelsColorMap{
"severity": {
"critical": models.LabelColors{Background: "#f00", Brightness: 100},
},
},
},
{
// label value stats with all fields
name: "LabelValueStats/full",
val: models.LabelValueStats{
Value: "critical",
Raw: "critical",
Hits: 10,
Percent: 50,
Offset: 0,
},
},
{
// label value stats list with multiple entries
name: "LabelValueStatsList/populated",
val: models.LabelValueStatsList{
{Value: "critical", Raw: "critical", Hits: 10, Percent: 50, Offset: 0},
{Value: "warning", Raw: "warning", Hits: 5, Percent: 25, Offset: 50},
},
},
{
// label name stats with nested values
name: "LabelNameStats/full",
val: models.LabelNameStats{
Name: "severity",
Values: models.LabelValueStatsList{
{Value: "critical", Raw: "critical", Hits: 10, Percent: 50, Offset: 0},
},
Hits: 10,
},
},
{
// label name stats list with multiple entries
name: "LabelNameStatsList/populated",
val: models.LabelNameStatsList{
{Name: "severity", Values: models.LabelValueStatsList{{Value: "critical", Raw: "critical", Hits: 10, Percent: 100, Offset: 0}}, Hits: 10},
},
},
{
// silence matcher with regex enabled
name: "SilenceMatcher/regex",
val: models.SilenceMatcher{
Name: "alertname",
Value: "test.*",
IsRegex: true,
IsEqual: true,
},
},
{
// silence with all fields and multiple matchers
name: "Silence/full",
val: models.Silence{
StartsAt: ts,
EndsAt: ts.Add(time.Hour),
CreatedAt: ts.Add(-time.Hour),
ID: "silence-123",
CreatedBy: "user@example.com",
Comment: "maintenance window",
TicketID: "JIRA-456",
TicketURL: "https://jira.example.com/JIRA-456",
Matchers: []models.SilenceMatcher{
{Name: "alertname", Value: "TestAlert", IsRegex: false, IsEqual: true},
},
},
},
{
// managed silence wrapping a silence with metadata
name: "ManagedSilence/full",
val: models.ManagedSilence{
Cluster: "prod",
Silence: models.Silence{
StartsAt: ts,
EndsAt: ts.Add(time.Hour),
CreatedAt: ts,
ID: "sil-1",
CreatedBy: "admin",
Comment: "test",
Matchers: []models.SilenceMatcher{{Name: "env", Value: "prod", IsEqual: true}},
},
AlertCount: 5,
IsExpired: false,
},
},
{
// alertmanager API status with all fields
name: "AlertmanagerAPIStatus/full",
val: models.AlertmanagerAPIStatus{
Headers: map[string]string{"X-Custom": "value"},
Name: "am1",
URI: "http://am1:9093",
PublicURI: "https://am1.example.com",
CORSCredentials: "include",
Error: "",
Version: "0.27.0",
Cluster: "prod",
ClusterMembers: []string{"am1", "am2"},
ReadOnly: false,
},
},
{
// alertmanager API counters
name: "AlertmanagerAPICounters/full",
val: models.AlertmanagerAPICounters{
Total: 3,
Healthy: 2,
Failed: 1,
},
},
{
// alertmanager API summary with nested instances and counters
name: "AlertmanagerAPISummary/full",
val: models.AlertmanagerAPISummary{
Clusters: map[string][]string{"prod": {"am1", "am2"}},
Instances: []models.AlertmanagerAPIStatus{
{
Headers: map[string]string{},
Name: "am1",
URI: "http://am1:9093",
Cluster: "prod",
ClusterMembers: []string{},
PublicURI: "",
CORSCredentials: "",
Error: "",
Version: "",
},
},
Counters: models.AlertmanagerAPICounters{Total: 1, Healthy: 1},
},
},
{
// grid settings
name: "GridSettings/full",
val: models.GridSettings{
Order: "startsAt",
Label: "severity",
Reverse: true,
},
},
{
// sort settings with nested grid and value mapping
name: "SortSettings/full",
val: models.SortSettings{
ValueMapping: map[string]map[string]string{
"severity": {"critical": "1", "warning": "2"},
},
Grid: models.GridSettings{Order: "label", Label: "cluster", Reverse: false},
},
},
{
// silence form strip settings
name: "SilenceFormStripSettings/full",
val: models.SilenceFormStripSettings{
Labels: []string{"alertname", "severity"},
},
},
{
// silence form settings with nested strip
name: "SilenceFormSettings/full",
val: models.SilenceFormSettings{
Strip: models.SilenceFormStripSettings{Labels: []string{"job"}},
DefaultAlertmanagers: []string{"am1"},
},
},
{
// alert acknowledgement settings
name: "AlertAcknowledgementSettings/full",
val: models.AlertAcknowledgementSettings{
Author: "karma",
Comment: "ACK!",
DurationSeconds: 900,
Enabled: true,
},
},
{
// labels settings map with multiple entries
name: "LabelsSettings/populated",
val: models.LabelsSettings{
"alertname": {IsStatic: true, IsValueOnly: false},
"job": {IsStatic: false, IsValueOnly: true},
},
},
{
// full settings with all nested structures
name: "Settings/full",
val: models.Settings{
Labels: models.LabelsSettings{
"alertname": {IsStatic: true},
},
Sorting: models.SortSettings{
ValueMapping: map[string]map[string]string{},
Grid: models.GridSettings{Order: "label"},
},
SilenceForm: models.SilenceFormSettings{
Strip: models.SilenceFormStripSettings{Labels: []string{}},
DefaultAlertmanagers: []string{},
},
AnnotationsHidden: []string{"help"},
AnnotationsVisible: []string{"summary"},
AlertAcknowledgement: models.AlertAcknowledgementSettings{
Enabled: true, DurationSeconds: 900, Author: "karma", Comment: "ACK",
},
GridGroupLimit: 40,
AnnotationsDefaultHidden: false,
AnnotationsAllowHTML: true,
HistoryEnabled: true,
},
},
{
// authentication info
name: "AuthenticationInfo/full",
val: models.AuthenticationInfo{
Username: "admin",
Groups: []string{"ops", "dev"},
Enabled: true,
},
},
{
// autocomplete entry
name: "Autocomplete/full",
val: models.Autocomplete{
Value: "@alertname=TestAlert",
Tokens: []string{"alertname", "alertname=", "TestAlert"},
},
},
{
// counters with nested label name stats
name: "Counters/full",
val: models.Counters{
Counters: models.LabelNameStatsList{
{Name: "severity", Values: models.LabelValueStatsList{{Value: "critical", Raw: "critical", Hits: 5, Percent: 100, Offset: 0}}, Hits: 5},
},
Total: 5,
},
},
{
// API grid with state counts, label info, and nested alert groups
name: "APIGrid/full",
val: models.APIGrid{
StateCount: map[string]int{"active": 2, "suppressed": 1},
LabelName: "cluster",
LabelValue: "prod",
AlertGroups: []models.APIAlertGroup{
{
AllLabels: map[string]map[string][]string{},
AlertmanagerCount: map[string]int{"am1": 1},
StateCount: map[string]int{"active": 1},
Receiver: "default",
ID: "group-1",
Shared: models.APIAlertGroupSharedMaps{
Annotations: models.Annotations{},
Labels: models.OrderedLabels{},
Silences: map[string][]string{},
Sources: []string{},
Clusters: []string{},
},
Labels: models.OrderedLabels{},
Alerts: []models.APIAlert{},
},
},
TotalGroups: 1,
},
},
{
// API grid with populated alert groups containing actual alerts,
// exercising the inner alert marshaling loop in APIGrid.MarshalJSONTo
name: "APIGrid/with_alerts",
val: models.APIGrid{
StateCount: map[string]int{"active": 1},
LabelName: "cluster",
LabelValue: "prod",
AlertGroups: []models.APIAlertGroup{
{
AllLabels: map[string]map[string][]string{
"active": {"alertname": {"Test"}},
},
AlertmanagerCount: map[string]int{"am1": 1},
StateCount: map[string]int{"active": 1},
Receiver: "default",
ID: "group-1",
Shared: models.APIAlertGroupSharedMaps{
Annotations: models.Annotations{},
Labels: models.OrderedLabels{{Name: "job", Value: "test"}},
Silences: map[string][]string{},
Sources: []string{"http://am1:9093"},
Clusters: []string{"cluster1"},
},
Labels: models.OrderedLabels{{Name: "alertname", Value: "Test"}},
Alerts: []models.APIAlert{
{
StartsAt: ts,
State: "active",
Receiver: "default",
LabelsFP: "fp1",
Annotations: models.Annotations{
{Name: "summary", Value: "test", Visible: true},
},
Labels: models.OrderedLabels{
{Name: "instance", Value: "localhost:9090"},
},
Alertmanager: []models.AlertmanagerInstance{
{
StartsAt: ts,
Fingerprint: "fp1",
Name: "am1",
Cluster: "cluster1",
Source: "http://am1:9093",
SilencedBy: []string{},
InhibitedBy: []string{},
State: models.AlertStateActive,
},
},
},
},
TotalAlerts: 1,
},
},
TotalGroups: 1,
},
},
{
// full alert group with all nested structures populated
name: "APIAlertGroup/full",
val: models.APIAlertGroup{
AllLabels: map[string]map[string][]string{
"active": {
"alertname": {"TestAlert"},
"severity": {"critical", "warning"},
},
},
AlertmanagerCount: map[string]int{"am1": 2},
StateCount: map[string]int{"active": 1, "suppressed": 1},
Receiver: "default",
ID: "group-1",
Shared: models.APIAlertGroupSharedMaps{
Annotations: models.Annotations{},
Labels: models.OrderedLabels{{Name: "job", Value: "test"}},
Silences: map[string][]string{},
Sources: []string{"http://am1:9093"},
Clusters: []string{"cluster1"},
},
Labels: models.OrderedLabels{
{Name: "alertname", Value: "TestAlert"},
},
Alerts: []models.APIAlert{
{
StartsAt: ts,
State: "active",
Receiver: "default",
LabelsFP: "fp1",
Annotations: models.Annotations{
{Name: "summary", Value: "test", Visible: true},
},
Labels: models.OrderedLabels{
{Name: "instance", Value: "localhost:9090"},
},
Alertmanager: []models.AlertmanagerInstance{
{
StartsAt: ts,
Fingerprint: "fp1",
Name: "am1",
Cluster: "cluster1",
Source: "http://am1:9093",
SilencedBy: []string{},
InhibitedBy: []string{},
State: models.AlertStateActive,
},
},
},
},
TotalAlerts: 5,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// v1 encoding/json is the reference output
v1Bytes, err := json.Marshal(tc.val)
if err != nil {
t.Fatalf("v1 Marshal failed: %v", err)
}
// v2 will dispatch to our MarshalJSONTo methods
v2Bytes, err := jsonv2.Marshal(tc.val)
if err != nil {
t.Fatalf("v2 Marshal failed: %v", err)
}
// unmarshal both into any for comparison, this handles
// non-deterministic map key ordering
var v1Any, v2Any any
if err := json.Unmarshal(v1Bytes, &v1Any); err != nil {
t.Fatalf("v1 Unmarshal failed: %v", err)
}
if err := json.Unmarshal(v2Bytes, &v2Any); err != nil {
t.Fatalf("v2 Unmarshal failed: %v", err)
}
if !reflect.DeepEqual(v1Any, v2Any) {
t.Errorf("v1 vs v2 mismatch\nv1: %s\nv2: %s", v1Bytes, v2Bytes)
}
})
}
}