| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | //go:build integration |
| 4 | |
| 5 | package mssql |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "os" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/netdata/netdata/go/plugins/pkg/confopt" |
| 16 | |
| 17 | "github.com/stretchr/testify/assert" |
| 18 | "github.com/stretchr/testify/require" |
| 19 | ) |
| 20 | |
| 21 | // getDSN returns the DSN from MSSQL_DSN environment variable. |
| 22 | // If not set, the test is skipped. |
| 23 | func getDSN(t *testing.T) string { |
| 24 | dsn := os.Getenv("MSSQL_DSN") |
| 25 | if dsn == "" { |
| 26 | t.Skip("MSSQL_DSN environment variable not set") |
| 27 | } |
| 28 | return dsn |
| 29 | } |
| 30 | |
| 31 | // TestIntegration_FullCollection runs a complete integration test against a real SQL Server. |
| 32 | // Run with: MSSQL_DSN="sqlserver://user:pass@host:port" go test -tags=integration -v -run TestIntegration |
| 33 | func TestIntegration_FullCollection(t *testing.T) { |
| 34 | c := New() |
| 35 | c.DSN = getDSN(t) |
| 36 | c.Timeout = confopt.Duration(time.Second * 10) |
| 37 | |
| 38 | // Initialize |
| 39 | require.NoError(t, c.Init(context.Background()), "Init should succeed") |
| 40 | |
| 41 | // Check (first collection) |
| 42 | require.NoError(t, c.Check(context.Background()), "Check should succeed") |
| 43 | |
| 44 | t.Logf("Connected to SQL Server version: %s", c.version) |
| 45 | |
| 46 | // Collect multiple times to verify stability |
| 47 | for i := 1; i <= 3; i++ { |
| 48 | t.Logf("\n=== Collection cycle %d ===", i) |
| 49 | |
| 50 | mx := c.Collect(context.Background()) |
| 51 | require.NotNil(t, mx, "Collect should return metrics") |
| 52 | require.NotEmpty(t, mx, "Metrics should not be empty") |
| 53 | |
| 54 | // Verify charts were created |
| 55 | charts := c.Charts() |
| 56 | require.NotNil(t, charts) |
| 57 | t.Logf("Charts count: %d", len(*charts)) |
| 58 | |
| 59 | // Group and report metrics |
| 60 | reportMetrics(t, mx) |
| 61 | |
| 62 | // Verify key metrics exist |
| 63 | assertKeyMetrics(t, mx) |
| 64 | |
| 65 | time.Sleep(time.Second) |
| 66 | } |
| 67 | |
| 68 | // Cleanup |
| 69 | c.Cleanup(context.Background()) |
| 70 | assert.Nil(t, c.db, "DB connection should be closed after cleanup") |
| 71 | } |
| 72 | |
| 73 | func reportMetrics(t *testing.T, mx map[string]int64) { |
| 74 | // Group metrics by prefix |
| 75 | groups := make(map[string][]string) |
| 76 | for k := range mx { |
| 77 | prefix := getMetricPrefix(k) |
| 78 | groups[prefix] = append(groups[prefix], k) |
| 79 | } |
| 80 | |
| 81 | // Sort and print |
| 82 | var prefixes []string |
| 83 | for p := range groups { |
| 84 | prefixes = append(prefixes, p) |
| 85 | } |
| 86 | sort.Strings(prefixes) |
| 87 | |
| 88 | t.Logf("Total metrics: %d", len(mx)) |
| 89 | for _, prefix := range prefixes { |
| 90 | keys := groups[prefix] |
| 91 | sort.Strings(keys) |
| 92 | t.Logf(" [%s]: %d metrics", prefix, len(keys)) |
| 93 | |
| 94 | // Show a few sample values |
| 95 | for i, k := range keys { |
| 96 | if i >= 3 { |
| 97 | t.Logf(" ... and %d more", len(keys)-3) |
| 98 | break |
| 99 | } |
| 100 | t.Logf(" %s = %d", k, mx[k]) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func getMetricPrefix(key string) string { |
| 106 | parts := strings.Split(key, "_") |
| 107 | if len(parts) >= 2 { |
| 108 | // Handle special cases |
| 109 | if parts[0] == "database" { |
| 110 | return "database" |
| 111 | } |
| 112 | if parts[0] == "wait" { |
| 113 | return "wait" |
| 114 | } |
| 115 | if parts[0] == "locks" { |
| 116 | return "locks" |
| 117 | } |
| 118 | if parts[0] == "job" { |
| 119 | return "job" |
| 120 | } |
| 121 | return parts[0] |
| 122 | } |
| 123 | return key |
| 124 | } |
| 125 | |
| 126 | func assertKeyMetrics(t *testing.T, mx map[string]int64) { |
| 127 | // Instance metrics |
| 128 | requiredMetrics := []string{ |
| 129 | "batch_requests", |
| 130 | "sql_compilations", |
| 131 | "sql_recompilations", |
| 132 | } |
| 133 | |
| 134 | // Optional metrics (may not exist depending on config) |
| 135 | optionalMetrics := []string{ |
| 136 | "user_connections", |
| 137 | "blocked_processes", |
| 138 | "buffer_cache_hit_ratio", |
| 139 | "buffer_page_life_expectancy", |
| 140 | "buffer_page_reads", |
| 141 | "buffer_page_writes", |
| 142 | "memory_total", |
| 143 | "page_splits", |
| 144 | } |
| 145 | |
| 146 | for _, m := range requiredMetrics { |
| 147 | _, exists := mx[m] |
| 148 | assert.True(t, exists, "Required metric %s should exist", m) |
| 149 | } |
| 150 | |
| 151 | foundOptional := 0 |
| 152 | for _, m := range optionalMetrics { |
| 153 | if _, exists := mx[m]; exists { |
| 154 | foundOptional++ |
| 155 | } |
| 156 | } |
| 157 | t.Logf("Found %d/%d optional instance metrics", foundOptional, len(optionalMetrics)) |
| 158 | |
| 159 | // Check for database metrics (should have at least system databases) |
| 160 | dbMetrics := 0 |
| 161 | for k := range mx { |
| 162 | if strings.HasPrefix(k, "database_") { |
| 163 | dbMetrics++ |
| 164 | } |
| 165 | } |
| 166 | assert.Greater(t, dbMetrics, 0, "Should have database metrics") |
| 167 | t.Logf("Found %d database metrics", dbMetrics) |
| 168 | |
| 169 | // Check for wait metrics |
| 170 | waitMetrics := 0 |
| 171 | for k := range mx { |
| 172 | if strings.HasPrefix(k, "wait_") { |
| 173 | waitMetrics++ |
| 174 | } |
| 175 | } |
| 176 | assert.Greater(t, waitMetrics, 0, "Should have wait metrics") |
| 177 | t.Logf("Found %d wait metrics", waitMetrics) |
| 178 | } |
| 179 | |
| 180 | // TestIntegration_ChartsCreation verifies dynamic chart creation |
| 181 | func TestIntegration_ChartsCreation(t *testing.T) { |
| 182 | c := New() |
| 183 | c.DSN = getDSN(t) |
| 184 | c.Timeout = confopt.Duration(time.Second * 10) |
| 185 | |
| 186 | require.NoError(t, c.Init(context.Background())) |
| 187 | require.NoError(t, c.Check(context.Background())) |
| 188 | |
| 189 | // First collection creates charts |
| 190 | mx1 := c.Collect(context.Background()) |
| 191 | require.NotNil(t, mx1) |
| 192 | |
| 193 | charts := c.Charts() |
| 194 | initialChartCount := len(*charts) |
| 195 | t.Logf("Charts after first collection: %d", initialChartCount) |
| 196 | |
| 197 | // List chart IDs |
| 198 | var chartIDs []string |
| 199 | for _, ch := range *charts { |
| 200 | chartIDs = append(chartIDs, ch.ID) |
| 201 | } |
| 202 | sort.Strings(chartIDs) |
| 203 | |
| 204 | t.Log("Chart IDs:") |
| 205 | for _, id := range chartIDs { |
| 206 | t.Logf(" - %s", id) |
| 207 | } |
| 208 | |
| 209 | // Verify we have expected chart categories |
| 210 | hasInstance := false |
| 211 | hasDatabase := false |
| 212 | hasWait := false |
| 213 | |
| 214 | for _, id := range chartIDs { |
| 215 | if strings.Contains(id, "user_connections") || strings.Contains(id, "batch_requests") { |
| 216 | hasInstance = true |
| 217 | } |
| 218 | if strings.Contains(id, "database_") && strings.Contains(id, "_transactions") { |
| 219 | hasDatabase = true |
| 220 | } |
| 221 | if strings.Contains(id, "wait_") { |
| 222 | hasWait = true |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | assert.True(t, hasInstance, "Should have instance charts") |
| 227 | assert.True(t, hasDatabase, "Should have database charts") |
| 228 | assert.True(t, hasWait, "Should have wait charts") |
| 229 | |
| 230 | c.Cleanup(context.Background()) |
| 231 | } |
| 232 | |
| 233 | // TestIntegration_MetricValues verifies metric values are sensible |
| 234 | func TestIntegration_MetricValues(t *testing.T) { |
| 235 | c := New() |
| 236 | c.DSN = getDSN(t) |
| 237 | c.Timeout = confopt.Duration(time.Second * 10) |
| 238 | |
| 239 | require.NoError(t, c.Init(context.Background())) |
| 240 | require.NoError(t, c.Check(context.Background())) |
| 241 | |
| 242 | mx := c.Collect(context.Background()) |
| 243 | require.NotNil(t, mx) |
| 244 | |
| 245 | // Buffer cache hit ratio should be 0-100 |
| 246 | if v, ok := mx["buffer_cache_hit_ratio"]; ok { |
| 247 | assert.GreaterOrEqual(t, v, int64(0), "Cache hit ratio >= 0") |
| 248 | assert.LessOrEqual(t, v, int64(100), "Cache hit ratio <= 100") |
| 249 | t.Logf("Buffer cache hit ratio: %d%%", v) |
| 250 | } |
| 251 | |
| 252 | // Page life expectancy should be positive |
| 253 | if v, ok := mx["buffer_page_life_expectancy"]; ok { |
| 254 | assert.GreaterOrEqual(t, v, int64(0), "PLE should be >= 0") |
| 255 | t.Logf("Page life expectancy: %d seconds", v) |
| 256 | } |
| 257 | |
| 258 | // User connections should be at least 1 (our connection) |
| 259 | if v, ok := mx["user_connections"]; ok { |
| 260 | assert.GreaterOrEqual(t, v, int64(1), "Should have at least 1 connection") |
| 261 | t.Logf("User connections: %d", v) |
| 262 | } |
| 263 | |
| 264 | // Memory should be positive |
| 265 | if v, ok := mx["memory_total"]; ok { |
| 266 | assert.Greater(t, v, int64(0), "Memory should be > 0") |
| 267 | t.Logf("Total memory: %d bytes (%.2f MB)", v, float64(v)/1024/1024) |
| 268 | } |
| 269 | |
| 270 | // Blocked processes should be non-negative |
| 271 | if v, ok := mx["blocked_processes"]; ok { |
| 272 | assert.GreaterOrEqual(t, v, int64(0), "Blocked processes >= 0") |
| 273 | t.Logf("Blocked processes: %d", v) |
| 274 | } |
| 275 | |
| 276 | c.Cleanup(context.Background()) |
| 277 | } |
| 278 | |
| 279 | // TestIntegration_ErrorHandling verifies graceful error handling |
| 280 | func TestIntegration_ErrorHandling(t *testing.T) { |
| 281 | // Test with invalid DSN - doesn't need a real server |
| 282 | c := New() |
| 283 | c.DSN = "sqlserver://invalid:invalid@localhost:9999?connection+timeout=2" |
| 284 | c.Timeout = confopt.Duration(time.Second * 3) |
| 285 | |
| 286 | require.NoError(t, c.Init(context.Background()), "Init should succeed (just validates config)") |
| 287 | |
| 288 | // Check should fail with connection error |
| 289 | err := c.Check(context.Background()) |
| 290 | assert.Error(t, err, "Check should fail with invalid connection") |
| 291 | t.Logf("Expected error: %v", err) |
| 292 | } |
| 293 | |
| 294 | // TestIntegration_Databases lists all discovered databases |
| 295 | func TestIntegration_Databases(t *testing.T) { |
| 296 | c := New() |
| 297 | c.DSN = getDSN(t) |
| 298 | c.Timeout = confopt.Duration(time.Second * 10) |
| 299 | |
| 300 | require.NoError(t, c.Init(context.Background())) |
| 301 | require.NoError(t, c.Check(context.Background())) |
| 302 | |
| 303 | _ = c.Collect(context.Background()) |
| 304 | |
| 305 | t.Log("Discovered databases:") |
| 306 | for db := range c.seenDatabases { |
| 307 | t.Logf(" - %s", db) |
| 308 | } |
| 309 | |
| 310 | // Should have system databases |
| 311 | assert.True(t, c.seenDatabases["master"], "Should see master database") |
| 312 | assert.True(t, c.seenDatabases["tempdb"], "Should see tempdb database") |
| 313 | assert.True(t, c.seenDatabases["msdb"], "Should see msdb database") |
| 314 | assert.True(t, c.seenDatabases["model"], "Should see model database") |
| 315 | |
| 316 | c.Cleanup(context.Background()) |
| 317 | } |
| 318 | |
| 319 | // TestIntegration_WaitTypes lists all discovered wait types |
| 320 | func TestIntegration_WaitTypes(t *testing.T) { |
| 321 | c := New() |
| 322 | c.DSN = getDSN(t) |
| 323 | c.Timeout = confopt.Duration(time.Second * 10) |
| 324 | |
| 325 | require.NoError(t, c.Init(context.Background())) |
| 326 | require.NoError(t, c.Check(context.Background())) |
| 327 | |
| 328 | _ = c.Collect(context.Background()) |
| 329 | |
| 330 | t.Logf("Discovered wait types: %d", len(c.seenWaitTypes)) |
| 331 | |
| 332 | // Group by category |
| 333 | byCategory := make(map[string][]string) |
| 334 | for wt := range c.seenWaitTypes { |
| 335 | cat := getWaitCategory(wt) |
| 336 | byCategory[cat] = append(byCategory[cat], wt) |
| 337 | } |
| 338 | |
| 339 | for cat, types := range byCategory { |
| 340 | sort.Strings(types) |
| 341 | t.Logf(" [%s]: %d types", cat, len(types)) |
| 342 | for i, wt := range types { |
| 343 | if i >= 5 { |
| 344 | t.Logf(" ... and %d more", len(types)-5) |
| 345 | break |
| 346 | } |
| 347 | t.Logf(" - %s", wt) |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | c.Cleanup(context.Background()) |
| 352 | } |