| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package program |
| 4 | |
| 5 | import ( |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/stretchr/testify/assert" |
| 9 | "github.com/stretchr/testify/require" |
| 10 | ) |
| 11 | |
| 12 | type trueMatcher struct{} |
| 13 | |
| 14 | func (trueMatcher) Matches(_ string, _ SelectorLabels) bool { return true } |
| 15 | |
| 16 | func TestNewProgramScenarios(t *testing.T) { |
| 17 | tests := map[string]struct { |
| 18 | version string |
| 19 | metrics []string |
| 20 | charts []Chart |
| 21 | wantErr bool |
| 22 | assert func(t *testing.T, p *Program) |
| 23 | }{ |
| 24 | "builds immutable snapshot and deterministic metric ordering": { |
| 25 | version: "v1", |
| 26 | metrics: []string{"windows_tx_total", "windows_rx_total", "windows_tx_total"}, |
| 27 | charts: []Chart{ |
| 28 | sampleChart("win.nic.traffic"), |
| 29 | }, |
| 30 | assert: func(t *testing.T, p *Program) { |
| 31 | t.Helper() |
| 32 | |
| 33 | gotMetrics := p.MetricNames() |
| 34 | require.Len(t, gotMetrics, 2) |
| 35 | assert.Equal(t, []string{"windows_rx_total", "windows_tx_total"}, gotMetrics) |
| 36 | |
| 37 | gotCharts := p.Charts() |
| 38 | require.Len(t, gotCharts, 1) |
| 39 | |
| 40 | // Mutate returned chart copy and ensure Program internals are unaffected. |
| 41 | gotCharts[0].Meta.Title = "mutated-title" |
| 42 | again, ok := p.Chart("win.nic.traffic") |
| 43 | require.True(t, ok, "Chart() did not return existing template id") |
| 44 | assert.Equal(t, "Network traffic", again.Meta.Title) |
| 45 | }, |
| 46 | }, |
| 47 | "rejects duplicate chart template IDs": { |
| 48 | version: "v1", |
| 49 | metrics: []string{"windows_rx_total"}, |
| 50 | charts: []Chart{ |
| 51 | sampleChart("dup"), |
| 52 | sampleChart("dup"), |
| 53 | }, |
| 54 | wantErr: true, |
| 55 | }, |
| 56 | "rejects empty metric name": { |
| 57 | version: "v1", |
| 58 | metrics: []string{"windows_rx_total", ""}, |
| 59 | charts: []Chart{ |
| 60 | sampleChart("win.nic.traffic"), |
| 61 | }, |
| 62 | wantErr: true, |
| 63 | }, |
| 64 | "rejects missing selector matcher": { |
| 65 | version: "v1", |
| 66 | metrics: []string{"windows_rx_total"}, |
| 67 | charts: []Chart{ |
| 68 | { |
| 69 | TemplateID: "invalid", |
| 70 | Meta: ChartMeta{ |
| 71 | Title: "Invalid", |
| 72 | Family: "net", |
| 73 | Context: "win.invalid", |
| 74 | Units: "bytes/s", |
| 75 | Algorithm: AlgorithmIncremental, |
| 76 | Type: ChartTypeLine, |
| 77 | }, |
| 78 | Identity: ChartIdentity{ |
| 79 | IDTemplate: Template{Raw: "invalid"}, |
| 80 | Static: true, |
| 81 | }, |
| 82 | Labels: LabelPolicy{ |
| 83 | Mode: PromotionModeAutoIntersection, |
| 84 | Precedence: DefaultLabelPrecedence(), |
| 85 | }, |
| 86 | Lifecycle: LifecyclePolicy{}, |
| 87 | CollisionReduce: ReduceSum, |
| 88 | Dimensions: []Dimension{ |
| 89 | { |
| 90 | Selector: SelectorBinding{ |
| 91 | Expression: "windows_rx_total", |
| 92 | }, |
| 93 | NameTemplate: Template{Raw: "received"}, |
| 94 | }, |
| 95 | }, |
| 96 | }, |
| 97 | }, |
| 98 | wantErr: true, |
| 99 | }, |
| 100 | "rejects invalid chart type": { |
| 101 | version: "v1", |
| 102 | metrics: []string{"windows_rx_total"}, |
| 103 | charts: []Chart{ |
| 104 | func() Chart { |
| 105 | chart := sampleChart("invalid-type") |
| 106 | chart.Meta.Type = ChartType("bars") |
| 107 | return chart |
| 108 | }(), |
| 109 | }, |
| 110 | wantErr: true, |
| 111 | }, |
| 112 | "rejects missing label promotion mode": { |
| 113 | version: "v1", |
| 114 | metrics: []string{"windows_rx_total"}, |
| 115 | charts: []Chart{ |
| 116 | func() Chart { |
| 117 | chart := sampleChart("missing-label-mode") |
| 118 | chart.Labels.Mode = "" |
| 119 | return chart |
| 120 | }(), |
| 121 | }, |
| 122 | wantErr: true, |
| 123 | }, |
| 124 | "rejects negation-only instance selectors": { |
| 125 | version: "v1", |
| 126 | metrics: []string{"windows_rx_total"}, |
| 127 | charts: []Chart{ |
| 128 | func() Chart { |
| 129 | chart := sampleChart("negation-only-instance-selectors") |
| 130 | chart.Identity.InstanceByLabels = []InstanceLabelSelector{ |
| 131 | {Exclude: true, Key: "nic"}, |
| 132 | } |
| 133 | return chart |
| 134 | }(), |
| 135 | }, |
| 136 | wantErr: true, |
| 137 | }, |
| 138 | "rejects malformed instance selector keys": { |
| 139 | version: "v1", |
| 140 | metrics: []string{"windows_rx_total"}, |
| 141 | charts: []Chart{ |
| 142 | func() Chart { |
| 143 | chart := sampleChart("malformed-instance-selector") |
| 144 | chart.Identity.InstanceByLabels = []InstanceLabelSelector{ |
| 145 | {Key: "nic"}, |
| 146 | {Exclude: true, Key: " host"}, |
| 147 | } |
| 148 | return chart |
| 149 | }(), |
| 150 | }, |
| 151 | wantErr: true, |
| 152 | }, |
| 153 | } |
| 154 | |
| 155 | for name, tc := range tests { |
| 156 | t.Run(name, func(t *testing.T) { |
| 157 | p, err := New(tc.version, 42, tc.metrics, tc.charts) |
| 158 | if tc.wantErr { |
| 159 | require.Error(t, err) |
| 160 | return |
| 161 | } |
| 162 | require.NoError(t, err) |
| 163 | if tc.assert != nil { |
| 164 | tc.assert(t, p) |
| 165 | } |
| 166 | }) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestValidateInstanceLabelSelectorsReportsJoinedErrors(t *testing.T) { |
| 171 | err := validateInstanceLabelSelectors([]InstanceLabelSelector{ |
| 172 | {Exclude: true, Key: " host"}, |
| 173 | {}, |
| 174 | }) |
| 175 | |
| 176 | require.Error(t, err) |
| 177 | assert.ErrorContains(t, err, "instance selector[0]: exclude selector key must be trimmed") |
| 178 | assert.ErrorContains(t, err, "instance selector[1]: selector is empty") |
| 179 | assert.ErrorContains(t, err, "instance selectors must include at least one positive selector") |
| 180 | } |
| 181 | |
| 182 | func TestValidateDimensionReportsJoinedErrors(t *testing.T) { |
| 183 | err := validateDimension(Dimension{}) |
| 184 | |
| 185 | require.Error(t, err) |
| 186 | assert.ErrorContains(t, err, "selector expression is required") |
| 187 | assert.ErrorContains(t, err, "selector matcher is required") |
| 188 | assert.ErrorContains(t, err, "dimension name is required") |
| 189 | } |
| 190 | |
| 191 | func TestNewProgramReportsJoinedChartErrors(t *testing.T) { |
| 192 | chart := sampleChart("invalid-multi") |
| 193 | chart.Meta.Context = "" |
| 194 | chart.Meta.Units = "" |
| 195 | chart.Identity.InstanceByLabels = []InstanceLabelSelector{ |
| 196 | {Exclude: true, Key: "nic"}, |
| 197 | } |
| 198 | chart.CollisionReduce = "" |
| 199 | chart.Dimensions = []Dimension{{}} |
| 200 | |
| 201 | _, err := New("v1", 42, []string{"windows_rx_total"}, []Chart{chart}) |
| 202 | require.Error(t, err) |
| 203 | assert.ErrorContains(t, err, "context is required") |
| 204 | assert.ErrorContains(t, err, "units is required") |
| 205 | assert.ErrorContains(t, err, "identity: instance selectors must include at least one positive selector") |
| 206 | assert.ErrorContains(t, err, "collision reduce op is required") |
| 207 | assert.ErrorContains(t, err, "dimension[0]: selector expression is required") |
| 208 | assert.ErrorContains(t, err, "selector matcher is required") |
| 209 | assert.ErrorContains(t, err, "dimension name is required") |
| 210 | } |
| 211 | |
| 212 | func sampleChart(templateID string) Chart { |
| 213 | return Chart{ |
| 214 | TemplateID: templateID, |
| 215 | Meta: ChartMeta{ |
| 216 | Title: "Network traffic", |
| 217 | Family: "net", |
| 218 | Context: "win.nic_traffic", |
| 219 | Units: "bytes/s", |
| 220 | Algorithm: AlgorithmIncremental, |
| 221 | Type: ChartTypeLine, |
| 222 | Priority: 1000, |
| 223 | }, |
| 224 | Identity: ChartIdentity{ |
| 225 | IDTemplate: Template{Raw: "win_nic_{nic}_traffic"}, |
| 226 | InstanceByLabels: []InstanceLabelSelector{ |
| 227 | {Key: "nic"}, |
| 228 | }, |
| 229 | }, |
| 230 | Labels: LabelPolicy{ |
| 231 | Mode: PromotionModeAutoIntersection, |
| 232 | PromoteKeys: []string{"interface_type"}, |
| 233 | Exclusions: LabelExclusions{ |
| 234 | SelectorConstrainedKeys: []string{"direction"}, |
| 235 | }, |
| 236 | Precedence: DefaultLabelPrecedence(), |
| 237 | }, |
| 238 | Lifecycle: LifecyclePolicy{ |
| 239 | MaxInstances: 512, |
| 240 | ExpireAfterCycles: 10, |
| 241 | Dimensions: DimensionLifecyclePolicy{ |
| 242 | MaxDims: 16, |
| 243 | ExpireAfterCycles: 10, |
| 244 | }, |
| 245 | }, |
| 246 | CollisionReduce: ReduceSum, |
| 247 | Dimensions: []Dimension{ |
| 248 | { |
| 249 | Selector: SelectorBinding{ |
| 250 | Expression: "windows_rx_total", |
| 251 | Matcher: trueMatcher{}, |
| 252 | MetricNames: []string{"windows_rx_total"}, |
| 253 | ConstrainedLabelKeys: []string{"direction"}, |
| 254 | }, |
| 255 | NameTemplate: Template{ |
| 256 | Raw: "received", |
| 257 | }, |
| 258 | }, |
| 259 | { |
| 260 | Selector: SelectorBinding{ |
| 261 | Expression: "windows_tx_total", |
| 262 | Matcher: trueMatcher{}, |
| 263 | MetricNames: []string{"windows_tx_total"}, |
| 264 | ConstrainedLabelKeys: []string{"direction"}, |
| 265 | }, |
| 266 | NameTemplate: Template{ |
| 267 | Raw: "sent", |
| 268 | }, |
| 269 | }, |
| 270 | }, |
| 271 | } |
| 272 | } |