master
go 593 lines 15.7 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package sql
4
5 import (
6 "context"
7 "database/sql"
8 "io"
9 "testing"
10 "time"
11
12 "github.com/DATA-DOG/go-sqlmock"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "github.com/netdata/netdata/go/plugins/pkg/confopt"
17 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
18 "github.com/netdata/netdata/go/plugins/plugin/framework/jobruntime"
19 )
20
21 func TestNormalizeValue(t *testing.T) {
22 tests := []struct {
23 name string
24 input any
25 expected any
26 }{
27 {"nil", nil, nil},
28 {"string", "hello", "hello"},
29 {"int64", int64(42), int64(42)},
30 {"int", int(42), int64(42)},
31 {"int32", int32(42), int64(42)},
32 {"float64", float64(3.14), float64(3.14)},
33 {"float32", float32(3.14), float64(float32(3.14))},
34 {"bytes", []byte("hello"), "hello"},
35 {"bool", true, true},
36 }
37
38 for _, tc := range tests {
39 t.Run(tc.name, func(t *testing.T) {
40 result := normalizeValue(tc.input)
41 assert.Equal(t, tc.expected, result)
42 })
43 }
44 }
45
46 func TestNormalizeValue_Time(t *testing.T) {
47 ts := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
48 result := normalizeValue(ts)
49 assert.Equal(t, ts.UnixMilli(), result)
50 }
51
52 func TestConfigFunction_Validate(t *testing.T) {
53 tests := []struct {
54 name string
55 cfg ConfigFunction
56 expectError bool
57 errorCount int
58 }{
59 {
60 name: "valid config",
61 cfg: ConfigFunction{ID: "test", Query: "SELECT 1"},
62 expectError: false,
63 },
64 {
65 name: "valid config with limit",
66 cfg: ConfigFunction{ID: "test", Query: "SELECT 1", Limit: 500},
67 expectError: false,
68 },
69 {
70 name: "missing id",
71 cfg: ConfigFunction{Query: "SELECT 1"},
72 expectError: true,
73 errorCount: 1,
74 },
75 {
76 name: "missing query",
77 cfg: ConfigFunction{ID: "test"},
78 expectError: true,
79 errorCount: 1,
80 },
81 {
82 name: "missing both",
83 cfg: ConfigFunction{},
84 expectError: true,
85 errorCount: 2,
86 },
87 {
88 name: "id contains colon",
89 cfg: ConfigFunction{ID: "test:query", Query: "SELECT 1"},
90 expectError: true,
91 errorCount: 1,
92 },
93 {
94 name: "negative limit",
95 cfg: ConfigFunction{ID: "test", Query: "SELECT 1", Limit: -1},
96 expectError: true,
97 errorCount: 1,
98 },
99 {
100 name: "limit exceeds maximum",
101 cfg: ConfigFunction{ID: "test", Query: "SELECT 1", Limit: 10001},
102 expectError: true,
103 errorCount: 1,
104 },
105 {
106 name: "negative timeout",
107 cfg: ConfigFunction{ID: "test", Query: "SELECT 1", Timeout: confopt.Duration(-time.Second)},
108 expectError: true,
109 errorCount: 1,
110 },
111 {
112 name: "invalid column type",
113 cfg: ConfigFunction{
114 ID: "test",
115 Query: "SELECT 1",
116 Columns: map[string]ConfigFuncColumn{"col1": {Type: "invalid"}},
117 },
118 expectError: true,
119 errorCount: 1,
120 },
121 {
122 name: "valid column types",
123 cfg: ConfigFunction{
124 ID: "test",
125 Query: "SELECT 1",
126 Columns: map[string]ConfigFuncColumn{
127 "col1": {Type: "string"},
128 "col2": {Type: "integer"},
129 "col3": {Type: "float"},
130 "col4": {Type: "boolean"},
131 "col5": {Type: "duration"},
132 "col6": {Type: "timestamp"},
133 },
134 },
135 expectError: false,
136 },
137 }
138
139 for _, tc := range tests {
140 t.Run(tc.name, func(t *testing.T) {
141 seen := make(map[string]bool)
142 errs := tc.cfg.validate(0, seen)
143 if tc.expectError {
144 assert.Len(t, errs, tc.errorCount)
145 } else {
146 assert.Empty(t, errs)
147 }
148 })
149 }
150 }
151
152 func TestConfigFunction_Validate_DuplicateID(t *testing.T) {
153 seen := map[string]bool{"existing": true}
154 cfg := ConfigFunction{ID: "existing", Query: "SELECT 1"}
155
156 errs := cfg.validate(0, seen)
157 assert.Len(t, errs, 1)
158 assert.Contains(t, errs[0].Error(), "duplicate id")
159 }
160
161 func TestFuncTable_FindFunction(t *testing.T) {
162 c := &Collector{}
163 c.Config.Functions = []ConfigFunction{
164 {ID: "func1", Query: "SELECT 1"},
165 {ID: "func2", Query: "SELECT 2"},
166 }
167
168 ft := &funcTable{collector: c}
169
170 // Found
171 f := ft.findFunction("func1")
172 assert.NotNil(t, f)
173 assert.Equal(t, "func1", f.ID)
174
175 f = ft.findFunction("func2")
176 assert.NotNil(t, f)
177 assert.Equal(t, "func2", f.ID)
178
179 // Not found
180 f = ft.findFunction("nonexistent")
181 assert.Nil(t, f)
182 }
183
184 func TestSqlJobMethods(t *testing.T) {
185 tests := map[string]struct {
186 setupCollector func() *Collector
187 jobName string
188 wantLen int
189 wantMethodIDs []string
190 }{
191 "collector with single function": {
192 setupCollector: func() *Collector {
193 c := New()
194 c.Config.Functions = []ConfigFunction{
195 {ID: "test-query", Query: "SELECT 1", Description: "Test query"},
196 }
197 return c
198 },
199 jobName: "postgres_test",
200 wantLen: 1,
201 wantMethodIDs: []string{"postgres_test:test-query"},
202 },
203 "collector with multiple functions": {
204 setupCollector: func() *Collector {
205 c := New()
206 c.Config.Functions = []ConfigFunction{
207 {ID: "active-queries", Query: "SELECT 1"},
208 {ID: "databases", Query: "SELECT 2"},
209 {ID: "roles", Query: "SELECT 3"},
210 }
211 return c
212 },
213 jobName: "pg_main",
214 wantLen: 3,
215 wantMethodIDs: []string{
216 "pg_main:active-queries",
217 "pg_main:databases",
218 "pg_main:roles",
219 },
220 },
221 "collector without functions": {
222 setupCollector: func() *Collector {
223 return New()
224 },
225 jobName: "empty_job",
226 wantLen: 0,
227 },
228 }
229
230 for name, tc := range tests {
231 t.Run(name, func(t *testing.T) {
232 c := tc.setupCollector()
233 job := jobruntime.NewJob(jobruntime.JobConfig{
234 Name: tc.jobName,
235 ModuleName: "sql",
236 FullName: "sql_" + tc.jobName,
237 Module: c,
238 Out: io.Discard,
239 })
240 methods := sqlJobMethods(job)
241
242 assert.Len(t, methods, tc.wantLen)
243 for i, wantID := range tc.wantMethodIDs {
244 assert.Equal(t, wantID, methods[i].ID)
245 assert.Equal(t, 10, methods[i].UpdateEvery)
246 }
247 })
248 }
249 }
250
251 func TestFuncTable_MethodParams(t *testing.T) {
252 // MethodParams now always returns nil since each function is a separate endpoint
253 c := New()
254 c.db, _, _ = sqlmock.New()
255 c.Config.Functions = []ConfigFunction{
256 {ID: "func1", Query: "SELECT 1"},
257 {ID: "func2", Query: "SELECT 2"},
258 }
259 defer func() { _ = c.db.Close() }()
260
261 ft := &funcTable{collector: c}
262 params, err := ft.MethodParams(context.Background(), "postgres_test:func1")
263
264 require.NoError(t, err)
265 assert.Nil(t, params)
266 }
267
268 func TestFuncTable_Handle(t *testing.T) {
269 tests := map[string]struct {
270 functions []ConfigFunction
271 functionID string
272 dbNil bool
273 prepareMock func(sqlmock.Sqlmock)
274 checkResp func(*testing.T, *funcapi.FunctionResponse)
275 }{
276 "db not initialized": {
277 functions: []ConfigFunction{{ID: "test", Query: "SELECT 1"}},
278 functionID: "test",
279 dbNil: true,
280 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
281 assert.Equal(t, 503, resp.Status)
282 assert.Contains(t, resp.Message, "not initialized")
283 },
284 },
285 "unknown function": {
286 functions: []ConfigFunction{{ID: "known", Query: "SELECT 1"}},
287 functionID: "unknown",
288 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
289 assert.Equal(t, 404, resp.Status)
290 assert.Contains(t, resp.Message, "unknown function")
291 },
292 },
293 "successful query": {
294 functions: []ConfigFunction{
295 {ID: "test", Query: "SELECT id, name FROM users", Description: "Test query"},
296 },
297 functionID: "test",
298 prepareMock: func(m sqlmock.Sqlmock) {
299 rows := sqlmock.NewRows([]string{"id", "name"}).
300 AddRow(1, "Alice").
301 AddRow(2, "Bob")
302 m.ExpectQuery("SELECT id, name FROM users").WillReturnRows(rows)
303 },
304 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
305 assert.Equal(t, 200, resp.Status)
306 assert.Equal(t, "Test query", resp.Help)
307 assert.Len(t, resp.Data, 2)
308 },
309 },
310 "query error": {
311 functions: []ConfigFunction{{ID: "test", Query: "SELECT 1"}},
312 functionID: "test",
313 prepareMock: func(m sqlmock.Sqlmock) {
314 m.ExpectQuery("SELECT 1").WillReturnError(assert.AnError)
315 },
316 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
317 assert.Equal(t, 500, resp.Status)
318 assert.Contains(t, resp.Message, "query failed")
319 },
320 },
321 "limit applied": {
322 functions: []ConfigFunction{
323 {ID: "test", Query: "SELECT n", Limit: 2},
324 },
325 functionID: "test",
326 prepareMock: func(m sqlmock.Sqlmock) {
327 rows := sqlmock.NewRows([]string{"n"}).
328 AddRow(1).AddRow(2).AddRow(3).AddRow(4).AddRow(5)
329 m.ExpectQuery("SELECT n").WillReturnRows(rows)
330 },
331 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
332 assert.Equal(t, 200, resp.Status)
333 assert.Len(t, resp.Data, 2) // limited to 2
334 },
335 },
336 "default limit applied": {
337 functions: []ConfigFunction{
338 {ID: "test", Query: "SELECT n"}, // no limit set
339 },
340 functionID: "test",
341 prepareMock: func(m sqlmock.Sqlmock) {
342 rows := sqlmock.NewRows([]string{"n"})
343 for i := range 150 {
344 rows.AddRow(i)
345 }
346 m.ExpectQuery("SELECT n").WillReturnRows(rows)
347 },
348 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
349 assert.Equal(t, 200, resp.Status)
350 assert.Len(t, resp.Data, defaultFunctionLimit) // 100
351 },
352 },
353 "default_sort valid column": {
354 functions: []ConfigFunction{
355 {ID: "test", Query: "SELECT id, name", DefaultSort: "id"},
356 },
357 functionID: "test",
358 prepareMock: func(m sqlmock.Sqlmock) {
359 rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "test")
360 m.ExpectQuery("SELECT id, name").WillReturnRows(rows)
361 },
362 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
363 assert.Equal(t, 200, resp.Status)
364 assert.Equal(t, "id", resp.DefaultSortColumn)
365 },
366 },
367 "default_sort invalid column": {
368 functions: []ConfigFunction{
369 {ID: "test", Query: "SELECT id, name", DefaultSort: "nonexistent"},
370 },
371 functionID: "test",
372 prepareMock: func(m sqlmock.Sqlmock) {
373 rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "test")
374 m.ExpectQuery("SELECT id, name").WillReturnRows(rows)
375 },
376 checkResp: func(t *testing.T, resp *funcapi.FunctionResponse) {
377 assert.Equal(t, 200, resp.Status)
378 assert.Equal(t, "", resp.DefaultSortColumn) // cleared because invalid
379 },
380 },
381 }
382
383 for name, tc := range tests {
384 t.Run(name, func(t *testing.T) {
385 c := New()
386 c.Config.Functions = tc.functions
387
388 var mock sqlmock.Sqlmock
389 if !tc.dbNil {
390 var db *sql.DB
391 var err error
392 db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
393 require.NoError(t, err)
394 defer func() { _ = db.Close() }()
395 c.db = db
396
397 if tc.prepareMock != nil {
398 tc.prepareMock(mock)
399 }
400 }
401
402 ft := &funcTable{collector: c}
403
404 // Method format is "jobName:functionID" - function ID is extracted from method
405 method := "test_job:" + tc.functionID
406 resp := ft.Handle(context.Background(), method, nil)
407
408 tc.checkResp(t, resp)
409 if mock != nil {
410 assert.NoError(t, mock.ExpectationsWereMet())
411 }
412 })
413 }
414 }
415
416 func TestInferType(t *testing.T) {
417 tests := []struct {
418 dbType string
419 expected funcapi.FieldType
420 }{
421 // MySQL (uppercase)
422 {"INT", funcapi.FieldTypeInteger},
423 {"BIGINT", funcapi.FieldTypeInteger},
424 {"VARCHAR", funcapi.FieldTypeString},
425 {"DATETIME", funcapi.FieldTypeTimestamp},
426 {"TIME", funcapi.FieldTypeDuration},
427 {"FLOAT", funcapi.FieldTypeFloat},
428 {"DOUBLE", funcapi.FieldTypeFloat},
429 {"TEXT", funcapi.FieldTypeString},
430
431 // PostgreSQL (lowercase)
432 {"int4", funcapi.FieldTypeInteger},
433 {"int8", funcapi.FieldTypeInteger},
434 {"varchar", funcapi.FieldTypeString},
435 {"text", funcapi.FieldTypeString},
436 {"timestamp", funcapi.FieldTypeTimestamp},
437 {"timestamptz", funcapi.FieldTypeTimestamp},
438 {"bool", funcapi.FieldTypeBoolean},
439 {"boolean", funcapi.FieldTypeBoolean},
440 {"float8", funcapi.FieldTypeFloat},
441 {"numeric", funcapi.FieldTypeFloat},
442 {"interval", funcapi.FieldTypeDuration},
443
444 // SQL Server
445 {"NVARCHAR", funcapi.FieldTypeString},
446 {"DATETIME2", funcapi.FieldTypeTimestamp},
447 {"BIT", funcapi.FieldTypeBoolean},
448 {"REAL", funcapi.FieldTypeFloat},
449
450 // Oracle
451 {"VARCHAR2", funcapi.FieldTypeString},
452 {"NUMBER", funcapi.FieldTypeFloat},
453 {"BINARY_DOUBLE", funcapi.FieldTypeFloat},
454
455 // Case insensitive
456 {"int", funcapi.FieldTypeInteger},
457 {"Int", funcapi.FieldTypeInteger},
458 {"VARCHAR", funcapi.FieldTypeString},
459 {"Varchar", funcapi.FieldTypeString},
460
461 // Unknown -> string
462 {"UNKNOWN_TYPE", funcapi.FieldTypeString},
463 {"custom", funcapi.FieldTypeString},
464 {"", funcapi.FieldTypeString},
465 }
466
467 for _, tc := range tests {
468 t.Run(tc.dbType, func(t *testing.T) {
469 result := inferType(tc.dbType)
470 assert.Equal(t, tc.expected, result)
471 })
472 }
473 }
474
475 func TestParseFieldType(t *testing.T) {
476 tests := []struct {
477 input string
478 expected funcapi.FieldType
479 }{
480 {"string", funcapi.FieldTypeString},
481 {"STRING", funcapi.FieldTypeString},
482 {"integer", funcapi.FieldTypeInteger},
483 {"INTEGER", funcapi.FieldTypeInteger},
484 {"float", funcapi.FieldTypeFloat},
485 {"FLOAT", funcapi.FieldTypeFloat},
486 {"boolean", funcapi.FieldTypeBoolean},
487 {"BOOLEAN", funcapi.FieldTypeBoolean},
488 {"duration", funcapi.FieldTypeDuration},
489 {"DURATION", funcapi.FieldTypeDuration},
490 {"timestamp", funcapi.FieldTypeTimestamp},
491 {"TIMESTAMP", funcapi.FieldTypeTimestamp},
492 {"unknown", funcapi.FieldTypeString},
493 {"", funcapi.FieldTypeString},
494 }
495
496 for _, tc := range tests {
497 t.Run(tc.input, func(t *testing.T) {
498 result := parseFieldType(tc.input)
499 assert.Equal(t, tc.expected, result)
500 })
501 }
502 }
503
504 func TestDeriveTransform(t *testing.T) {
505 tests := []struct {
506 fieldType funcapi.FieldType
507 expected funcapi.FieldTransform
508 }{
509 {funcapi.FieldTypeInteger, funcapi.FieldTransformNumber},
510 {funcapi.FieldTypeFloat, funcapi.FieldTransformNumber},
511 {funcapi.FieldTypeDuration, funcapi.FieldTransformDuration},
512 {funcapi.FieldTypeTimestamp, funcapi.FieldTransformDatetime},
513 {funcapi.FieldTypeString, funcapi.FieldTransformNone},
514 {funcapi.FieldTypeBoolean, funcapi.FieldTransformNone},
515 }
516
517 for _, tc := range tests {
518 t.Run(tc.fieldType.String(), func(t *testing.T) {
519 result := deriveTransform(tc.fieldType)
520 assert.Equal(t, tc.expected, result)
521 })
522 }
523 }
524
525 func TestDeriveFilterSummary(t *testing.T) {
526 tests := []struct {
527 fieldType funcapi.FieldType
528 expectedFilter funcapi.FieldFilter
529 expectedSummary funcapi.FieldSummary
530 }{
531 {funcapi.FieldTypeInteger, funcapi.FieldFilterRange, funcapi.FieldSummarySum},
532 {funcapi.FieldTypeFloat, funcapi.FieldFilterRange, funcapi.FieldSummarySum},
533 {funcapi.FieldTypeDuration, funcapi.FieldFilterRange, funcapi.FieldSummarySum},
534 {funcapi.FieldTypeTimestamp, funcapi.FieldFilterRange, funcapi.FieldSummaryMax},
535 {funcapi.FieldTypeBoolean, funcapi.FieldFilterMultiselect, funcapi.FieldSummaryCount},
536 {funcapi.FieldTypeString, funcapi.FieldFilterMultiselect, funcapi.FieldSummaryCount},
537 }
538
539 for _, tc := range tests {
540 t.Run(tc.fieldType.String(), func(t *testing.T) {
541 filter, summary := deriveFilterSummary(tc.fieldType)
542 assert.Equal(t, tc.expectedFilter, filter)
543 assert.Equal(t, tc.expectedSummary, summary)
544 })
545 }
546 }
547
548 func TestDeriveNameFromID(t *testing.T) {
549 tests := []struct {
550 id string
551 expected string
552 }{
553 {"slow-queries", "Slow Queries"},
554 {"top_connections", "Top Connections"},
555 {"active-sessions", "Active Sessions"},
556 {"simple", "Simple"},
557 {"multi-word-id", "Multi Word Id"},
558 {"", ""},
559 }
560
561 for _, tc := range tests {
562 t.Run(tc.id, func(t *testing.T) {
563 result := deriveNameFromID(tc.id)
564 assert.Equal(t, tc.expected, result)
565 })
566 }
567 }
568
569 func TestConfigFunction_derivedName(t *testing.T) {
570 tests := []struct {
571 name string
572 cfg ConfigFunction
573 expected string
574 }{
575 {
576 name: "uses explicit name",
577 cfg: ConfigFunction{ID: "slow-queries", Name: "My Custom Name"},
578 expected: "My Custom Name",
579 },
580 {
581 name: "derives from ID",
582 cfg: ConfigFunction{ID: "slow-queries"},
583 expected: "Slow Queries",
584 },
585 }
586
587 for _, tc := range tests {
588 t.Run(tc.name, func(t *testing.T) {
589 result := tc.cfg.derivedName()
590 assert.Equal(t, tc.expected, result)
591 })
592 }
593 }